triad-plus 1.6.0 → 1.8.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +26 -0
  3. package/adapters/antigravity/.agents/agents/triad-evaluator/agent.md +5 -0
  4. package/adapters/claude-code/.claude/agents/triad-evaluator.md +5 -0
  5. package/adapters/copilot/.github/agents/triad-evaluator.agent.md +5 -0
  6. package/adapters/hermes/skills/triad/SKILL.md +5 -0
  7. package/adapters/opencode/.opencode/agents/triad-evaluator.md +6 -0
  8. package/bin/triad-plus.js +28 -3
  9. package/docs/architecture.md +7 -0
  10. package/docs/bmad-integration.md +110 -0
  11. package/docs/configuration.md +19 -0
  12. package/docs/evaluator-plus.md +9 -0
  13. package/docs/operating-guide.it.md +10 -0
  14. package/docs/operating-guide.md +10 -0
  15. package/docs/quality-contract.md +119 -0
  16. package/docs/verification.md +17 -0
  17. package/integrations/bmad/README.md +12 -0
  18. package/integrations/bmad/story-importer.mjs +475 -0
  19. package/package.json +2 -2
  20. package/runtime/lib/quality-baseline.mjs +205 -0
  21. package/runtime/triad-evaluator-validate.mjs +261 -0
  22. package/runtime/triad-verify.mjs +19 -5
  23. package/schemas/evaluator-plus-result.schema.json +16 -0
  24. package/schemas/quality-baseline.schema.json +42 -0
  25. package/schemas/verification-evidence.schema.json +1 -1
  26. package/skills/triad-loop-bootstrap/SKILL.md +8 -0
  27. package/skills/triad-loop-bootstrap/assets/loop-template/handoff-report.template.md +13 -0
  28. package/skills/triad-loop-bootstrap/assets/loop-template/quality-baseline.json +15 -0
  29. package/skills/triad-loop-bootstrap/assets/loop-template/run-state.yaml +7 -0
  30. package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +2 -0
  31. package/skills/triad-loop-bootstrap/assets/project.yaml +5 -0
  32. package/skills/triad-loop-evaluator/SKILL.md +12 -2
  33. package/skills/triad-loop-orchestrator/SKILL.md +51 -0
@@ -0,0 +1,475 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+
5
+ const READY_STATUS = 'ready-for-dev';
6
+ const SOURCE_KIND = 'bmad-story';
7
+
8
+ export class BmadStoryImportError extends Error {
9
+ constructor(code, message, details = {}) {
10
+ super(message);
11
+ this.name = 'BmadStoryImportError';
12
+ this.code = code;
13
+ this.details = details;
14
+ }
15
+ }
16
+
17
+ function sha256(value) {
18
+ return createHash('sha256').update(value).digest('hex');
19
+ }
20
+
21
+ function clean(value) {
22
+ return String(value ?? '').trim();
23
+ }
24
+
25
+ function unquote(value) {
26
+ const text = clean(value);
27
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
28
+ return text.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\'", "'");
29
+ }
30
+ return text;
31
+ }
32
+
33
+ function scalar(value) {
34
+ const text = unquote(value);
35
+ if (text === 'null' || text === '~') return null;
36
+ if (text === 'true') return true;
37
+ if (text === 'false') return false;
38
+ if (text.startsWith('[') && text.endsWith(']')) {
39
+ try {
40
+ const parsed = JSON.parse(text);
41
+ return parsed;
42
+ } catch {
43
+ return text;
44
+ }
45
+ }
46
+ return text;
47
+ }
48
+
49
+ function parseFrontmatter(source) {
50
+ if (!source.startsWith('---\n') && !source.startsWith('---\r\n')) return { metadata: {}, body: source };
51
+ const openingEnd = source.indexOf('\n');
52
+ const closing = source.indexOf('\n---', openingEnd + 1);
53
+ if (closing < 0) throw new BmadStoryImportError('bmad_story_invalid', 'BMAD Story frontmatter is not closed.');
54
+ const closingEnd = source.indexOf('\n', closing + 1);
55
+ const frontmatter = source.slice(openingEnd + 1, closing);
56
+ const metadata = {};
57
+ const duplicateKeys = [];
58
+ for (const line of frontmatter.split(/\r?\n/)) {
59
+ if (!line.trim() || line.trimStart().startsWith('#')) continue;
60
+ const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
61
+ if (!match) {
62
+ // BMAD frontmatter may contain nested lists/maps. They are not part of
63
+ // this importer contract, but are preserved in the source and ignored.
64
+ if (/^\s+/.test(line) || /^\s*-\s+/.test(line)) continue;
65
+ throw new BmadStoryImportError('bmad_story_invalid', `Malformed BMAD Story frontmatter line: ${line}`);
66
+ }
67
+ const [, key, rawValue] = match;
68
+ if (Object.hasOwn(metadata, key)) duplicateKeys.push(key);
69
+ metadata[key] = scalar(rawValue);
70
+ }
71
+ if (duplicateKeys.length) {
72
+ throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story frontmatter repeats: ${duplicateKeys.join(', ')}`);
73
+ }
74
+ return { metadata, body: closingEnd < 0 ? '' : source.slice(closingEnd + 1) };
75
+ }
76
+
77
+ function normalizeHeading(value) {
78
+ return clean(value)
79
+ .replaceAll('`', '')
80
+ .replace(/^\*+|\*+$/g, '')
81
+ .replace(/[::]+$/g, '')
82
+ .toLowerCase()
83
+ .replace(/&/g, 'and')
84
+ .replace(/[^a-z0-9]+/g, ' ')
85
+ .trim();
86
+ }
87
+
88
+ function parseSections(body) {
89
+ const sections = [];
90
+ let current = null;
91
+ const flush = () => {
92
+ if (!current) return;
93
+ const content = current.lines.join('\n').trim();
94
+ sections.push({ heading: current.heading, level: current.level, content });
95
+ };
96
+ for (const line of body.split(/\r?\n/)) {
97
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
98
+ if (heading) {
99
+ flush();
100
+ current = { heading: heading[2], level: heading[1].length, lines: [] };
101
+ } else if (current) {
102
+ current.lines.push(line);
103
+ }
104
+ }
105
+ flush();
106
+ return sections;
107
+ }
108
+
109
+ function parseLabelBlocks(body) {
110
+ const blocks = [];
111
+ let current = null;
112
+ const flush = () => {
113
+ if (!current) return;
114
+ const content = current.lines.join('\n').trim();
115
+ if (content) blocks.push({ heading: current.heading, content });
116
+ };
117
+ for (const line of body.split(/\r?\n/)) {
118
+ const label = line.match(/^\s*(?:[-*]\s*)?\*\*([^*]+)\*\*\s*:?\s*$/);
119
+ if (label) {
120
+ flush();
121
+ current = { heading: label[1], lines: [] };
122
+ } else if (/^#{1,6}\s+/.test(line)) {
123
+ flush();
124
+ current = null;
125
+ } else if (current) {
126
+ current.lines.push(line);
127
+ }
128
+ }
129
+ flush();
130
+ return blocks;
131
+ }
132
+
133
+ function valuesForMetadata(metadata, names) {
134
+ const values = [];
135
+ for (const name of names) if (metadata[name] !== undefined && metadata[name] !== null) values.push(metadata[name]);
136
+ return values;
137
+ }
138
+
139
+ function metadataLines(body, names) {
140
+ const wanted = new Set(names.map((name) => normalizeHeading(name)));
141
+ const values = [];
142
+ for (const line of body.split(/\r?\n/)) {
143
+ const match = line.match(/^\s*(?:[-*]\s*)?(?:\*\*)?([^:*]+?)(?:\*\*)?\s*:\s*(.+?)\s*$/);
144
+ if (match && wanted.has(normalizeHeading(match[1]))) values.push(unquote(match[2]));
145
+ }
146
+ return values;
147
+ }
148
+
149
+ function contentsFor(sections, labels, labelBlocks = []) {
150
+ const wanted = new Set(labels.map((label) => normalizeHeading(label)));
151
+ const values = [
152
+ ...sections.filter((section) => wanted.has(normalizeHeading(section.heading))).map((section) => section.content),
153
+ ...labelBlocks.filter((block) => wanted.has(normalizeHeading(block.heading))).map((block) => block.content)
154
+ ];
155
+ return [...new Set(values.map(clean).filter(Boolean))];
156
+ }
157
+
158
+ function firstContent(sections, labelBlocks, labels) {
159
+ const values = contentsFor(sections, labels, labelBlocks);
160
+ return values[0] ?? '';
161
+ }
162
+
163
+ function firstLabelOrSectionContent(sections, labelBlocks, labels) {
164
+ const labeled = contentsFor([], labels, labelBlocks);
165
+ return labeled[0] ?? firstContent(sections, labelBlocks, labels);
166
+ }
167
+
168
+ function distinctStrings(values, field) {
169
+ const normalized = values.map((value) => clean(value)).filter(Boolean);
170
+ const distinct = [...new Set(normalized)];
171
+ if (distinct.length > 1) {
172
+ throw new BmadStoryImportError('bmad_story_ambiguous', `BMAD Story has conflicting ${field} values.`, { field, values: distinct });
173
+ }
174
+ return distinct[0] ?? '';
175
+ }
176
+
177
+ function parseStoryHeading(sections) {
178
+ const headings = sections
179
+ .filter((section) => section.level === 1)
180
+ .map((section) => section.heading)
181
+ .map((heading) => {
182
+ const match = clean(heading).match(/^Story\s+([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*[:—-]\s*(.+))?$/i);
183
+ if (match) return { id: match[1], title: clean(match[2]) };
184
+ const plain = clean(heading).match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[:—-]\s*(.+)$/);
185
+ return plain ? { id: plain[1], title: clean(plain[2]) } : null;
186
+ })
187
+ .filter(Boolean);
188
+ if (headings.length > 1) {
189
+ throw new BmadStoryImportError('bmad_story_ambiguous', 'BMAD Story source contains multiple story headings.', { headings });
190
+ }
191
+ return headings[0] ?? { id: '', title: '' };
192
+ }
193
+
194
+ function normalizeList(value, field) {
195
+ if (value === undefined) return [];
196
+ if (!Array.isArray(value)) throw new BmadStoryImportError('bmad_story_unmappable', `${field} must be an array when supplied.`);
197
+ const result = [];
198
+ for (const item of value) {
199
+ if (typeof item !== 'string' || !item.trim()) throw new BmadStoryImportError('bmad_story_unmappable', `${field} must contain non-empty strings.`);
200
+ const normalized = item.trim();
201
+ if (!result.includes(normalized)) result.push(normalized);
202
+ }
203
+ return result;
204
+ }
205
+
206
+ function sourcePathLabel(sourcePath) {
207
+ return sourcePath ? resolve(sourcePath) : '<BMAD Story source>';
208
+ }
209
+
210
+ export function parseBmadStory(source, { sourcePath = null, targetRepository = null } = {}) {
211
+ if (typeof source !== 'string' || !source.trim()) {
212
+ throw new BmadStoryImportError('bmad_story_invalid', 'BMAD Story source is empty.');
213
+ }
214
+ const { metadata, body } = parseFrontmatter(source);
215
+ const sections = parseSections(body);
216
+ const labelBlocks = parseLabelBlocks(body);
217
+ const heading = parseStoryHeading(sections);
218
+
219
+ const id = distinctStrings([
220
+ ...valuesForMetadata(metadata, ['id', 'story_id', 'storyId']),
221
+ ...metadataLines(body, ['Story ID', 'ID']),
222
+ heading.id
223
+ ], 'story id');
224
+ if (!id) throw new BmadStoryImportError('bmad_story_not_found', `No BMAD Story id found in ${sourcePathLabel(sourcePath)}.`);
225
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
226
+ throw new BmadStoryImportError('bmad_story_invalid', `BMAD Story id is not filename-safe: ${id}`);
227
+ }
228
+
229
+ const title = distinctStrings([
230
+ ...valuesForMetadata(metadata, ['title']),
231
+ ...metadataLines(body, ['Title']),
232
+ heading.title
233
+ ], 'title');
234
+ if (!title) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story title is required.');
235
+
236
+ const status = distinctStrings([
237
+ ...valuesForMetadata(metadata, ['status']),
238
+ ...metadataLines(body, ['Status']),
239
+ firstContent(sections, labelBlocks, ['Status'])
240
+ ], 'status').toLowerCase();
241
+ if (!status) throw new BmadStoryImportError('bmad_story_not_ready', 'BMAD Story status is required and must be ready-for-dev.');
242
+ if (status !== READY_STATUS) {
243
+ throw new BmadStoryImportError('bmad_story_not_ready', `BMAD Story ${id} has status ${status}; only ready-for-dev can be imported.`, { status });
244
+ }
245
+
246
+ const declaredTargetRepository = distinctStrings([
247
+ ...valuesForMetadata(metadata, ['target_repository', 'targetRepository', 'repository', 'repo']),
248
+ ...metadataLines(body, ['Target repository', 'Target repo', 'Repository'])
249
+ ], 'target repository');
250
+ const explicitTargetRepository = targetRepository === undefined || targetRepository === null ? '' : clean(targetRepository);
251
+ if (targetRepository !== undefined && targetRepository !== null && !explicitTargetRepository) {
252
+ throw new BmadStoryImportError('bmad_story_unmappable', 'targetRepository must be a non-empty string when supplied.');
253
+ }
254
+ if (declaredTargetRepository && explicitTargetRepository && declaredTargetRepository !== explicitTargetRepository) {
255
+ throw new BmadStoryImportError('bmad_story_ambiguous', 'BMAD Story target repository conflicts with the explicit importer option.', {
256
+ declared: declaredTargetRepository,
257
+ explicit: explicitTargetRepository
258
+ });
259
+ }
260
+ const resolvedTargetRepository = declaredTargetRepository || explicitTargetRepository;
261
+ if (!resolvedTargetRepository) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story target repository is required.');
262
+
263
+ const outcome = firstContent(sections, labelBlocks, ['Intent', 'Outcome', 'User outcome', 'Objective', 'Story', 'Description']) || distinctStrings([
264
+ ...valuesForMetadata(metadata, ['intent', 'outcome', 'objective', 'description']),
265
+ ...metadataLines(body, ['Intent', 'Outcome', 'Objective', 'Description'])
266
+ ], 'intent/outcome');
267
+ if (!outcome) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story intent/outcome is required.');
268
+
269
+ const acceptanceCriteria = firstContent(sections, labelBlocks, ['Acceptance Criteria', 'Acceptance']) || '';
270
+ if (!acceptanceCriteria) throw new BmadStoryImportError('bmad_story_unmappable', 'BMAD Story acceptance criteria are required.');
271
+
272
+ return {
273
+ id,
274
+ title,
275
+ status,
276
+ targetRepository: resolvedTargetRepository,
277
+ branchWorktree: distinctStrings([
278
+ ...valuesForMetadata(metadata, ['branch', 'worktree', 'branch_worktree', 'branchWorktree']),
279
+ ...metadataLines(body, ['Branch', 'Worktree', 'Branch/worktree'])
280
+ ], 'branch/worktree'),
281
+ outcome,
282
+ acceptanceCriteria,
283
+ tasksAcceptance: firstLabelOrSectionContent(sections, labelBlocks, ['Execution', 'Tasks / Subtasks', 'Tasks', 'Tasks & Acceptance']),
284
+ codeMap: firstContent(sections, labelBlocks, ['Code Map', 'Technical Context', 'Technical context / code map', 'Implementation Context']),
285
+ designNotes: firstContent(sections, labelBlocks, ['Design Notes', 'Boundaries & Constraints', 'Constraints', 'Technical Constraints']),
286
+ verification: firstContent(sections, labelBlocks, ['Verification', 'Verification Expectations', 'Metrics and Gates', 'Verification expectations']),
287
+ references: firstContent(sections, labelBlocks, ['Source References', 'References', 'References / Provenance']),
288
+ inScope: firstContent(sections, labelBlocks, ['In Scope', 'Scope']),
289
+ outOfScope: firstContent(sections, labelBlocks, ['Out of Scope', 'Non-goals', 'Non Goals']),
290
+ sourcePath: sourcePath ? resolve(sourcePath) : null
291
+ };
292
+ }
293
+
294
+ function listText(values) {
295
+ return values.length ? values.map((value) => `\`${value}\``).join(', ') : 'none';
296
+ }
297
+
298
+ function sectionOrFallback(value, fallback) {
299
+ return value?.trim() || fallback;
300
+ }
301
+
302
+ export function buildTriadCard(story, { requiredGates = [], dependsOn = [] } = {}) {
303
+ if (!story || typeof story !== 'object') {
304
+ throw new BmadStoryImportError('bmad_story_unmappable', 'A parsed BMAD Story object is required.');
305
+ }
306
+ for (const field of ['id', 'title', 'targetRepository', 'outcome', 'acceptanceCriteria']) {
307
+ if (typeof story[field] !== 'string' || !story[field].trim()) {
308
+ throw new BmadStoryImportError('bmad_story_unmappable', `BMAD Story ${field} is required before Card generation.`);
309
+ }
310
+ }
311
+ if (story.status !== READY_STATUS) {
312
+ throw new BmadStoryImportError('bmad_story_not_ready', 'Only a ready-for-dev BMAD Story can become an executable Triad Card.', { status: story.status ?? null });
313
+ }
314
+ const gates = normalizeList(requiredGates, 'requiredGates');
315
+ const dependencies = normalizeList(dependsOn, 'dependsOn');
316
+ const branch = story.branchWorktree || 'not declared by BMAD Story';
317
+ const inScope = sectionOrFallback(story.inScope, 'Acceptance criteria, tasks, and technical context below define the bounded implementation; no additional scope is inferred.');
318
+ const outOfScope = sectionOrFallback(story.outOfScope, 'No additional out-of-scope constraints were declared by the BMAD Story.');
319
+ const tasks = sectionOrFallback(story.tasksAcceptance, 'No Tasks & Acceptance section was supplied by the BMAD Story.');
320
+ const codeMap = sectionOrFallback(story.codeMap, 'No Code Map or technical context section was supplied by the BMAD Story.');
321
+ const designNotes = sectionOrFallback(story.designNotes, 'No Design Notes or additional constraints were supplied by the BMAD Story.');
322
+ const verification = sectionOrFallback(story.verification, 'Use the repository-owned deterministic gates declared by the caller and the normal Triad verifier.');
323
+ const references = sectionOrFallback(story.references, 'No source references were declared by the BMAD Story.');
324
+
325
+ return [
326
+ `# ${story.id} — ${story.title}`,
327
+ '',
328
+ '## Outcome and scope',
329
+ '',
330
+ `- Target repository: \`${story.targetRepository}\``,
331
+ `- Branch/worktree: \`${branch}\``,
332
+ `- Outcome: ${story.outcome}`,
333
+ `- In scope: ${inScope}`,
334
+ `- Out of scope: ${outOfScope}`,
335
+ `- Dependencies: ${listText(dependencies)}`,
336
+ '',
337
+ '## Acceptance criteria',
338
+ '',
339
+ story.acceptanceCriteria,
340
+ '',
341
+ '## Tasks & Acceptance',
342
+ '',
343
+ tasks,
344
+ '',
345
+ '## Technical context / Code Map',
346
+ '',
347
+ codeMap,
348
+ '',
349
+ '## Design Notes / Constraints',
350
+ '',
351
+ designNotes,
352
+ '',
353
+ '## Verification expectations',
354
+ '',
355
+ verification,
356
+ '',
357
+ '## Source references',
358
+ '',
359
+ references,
360
+ '',
361
+ '## Metrics and gates',
362
+ '',
363
+ '| ID | Target | Evidence command or observation |',
364
+ '| --- | --- | --- |',
365
+ '| bmad-story-status | ready-for-dev source imported | source status and hash recorded in integration provenance |',
366
+ `- Required gates (\`required_gates\`): [${gates.join(', ')}]`,
367
+ '- Allowed dependencies: repository-owned dependencies only; no dependency was inferred from BMAD ordering.',
368
+ '',
369
+ '## Integration boundary',
370
+ '',
371
+ '- This card was generated from one BMAD Story; BMAD remains planning authority and Triad remains execution authority.',
372
+ '- The BMAD source is read-only. Provenance is recorded in the integration-side companion record.',
373
+ ''
374
+ ].join('\n');
375
+ }
376
+
377
+ export function validateTriadCard(card) {
378
+ const required = [
379
+ /^#\s+[^\n]+/m,
380
+ /^## Outcome and scope$/m,
381
+ /^## Acceptance criteria$/m,
382
+ /^## Metrics and gates$/m,
383
+ /Required gates \(`required_gates`\):/,
384
+ /^## Integration boundary$/m
385
+ ];
386
+ const missing = required.filter((pattern) => !pattern.test(card)).map((pattern) => pattern.toString());
387
+ return { valid: missing.length === 0, missing };
388
+ }
389
+
390
+ async function readStableSource(sourcePath) {
391
+ const resolved = resolve(sourcePath);
392
+ let first;
393
+ try {
394
+ const sourceStat = await stat(resolved);
395
+ if (!sourceStat.isFile()) throw new Error('not a file');
396
+ first = await readFile(resolved);
397
+ } catch (error) {
398
+ throw new BmadStoryImportError('bmad_story_not_found', `Cannot read BMAD Story source: ${resolved}`, { cause: error.code ?? 'not_a_file' });
399
+ }
400
+ const sourceText = first.toString('utf8');
401
+ let second;
402
+ try { second = await readFile(resolved); }
403
+ catch (error) { throw new BmadStoryImportError('bmad_story_source_mutated', `BMAD Story source changed during import: ${resolved}`, { cause: error.code ?? 'read_failed' }); }
404
+ if (!first.equals(second)) {
405
+ throw new BmadStoryImportError('bmad_story_source_mutated', `BMAD Story source changed during import: ${resolved}`);
406
+ }
407
+ return { resolved, source: sourceText, sourceSha256: sha256(first) };
408
+ }
409
+
410
+ export async function importBmadStory({ sourcePath, targetRepository = null, requiredGates = [], dependsOn = [], capturedAt = null } = {}) {
411
+ if (!sourcePath || typeof sourcePath !== 'string') throw new BmadStoryImportError('bmad_story_not_found', 'Provide a BMAD Story source path.');
412
+ const loaded = await readStableSource(sourcePath);
413
+ const story = parseBmadStory(loaded.source, { sourcePath: loaded.resolved, targetRepository });
414
+ const card = buildTriadCard(story, { requiredGates, dependsOn });
415
+ const cardValidation = validateTriadCard(card);
416
+ if (!cardValidation.valid) throw new BmadStoryImportError('bmad_story_unmappable', 'Generated Triad Card failed integration validation.', { missing: cardValidation.missing });
417
+ const provenance = {
418
+ schema_version: 1,
419
+ source_kind: SOURCE_KIND,
420
+ source_path: loaded.resolved,
421
+ source_sha256: loaded.sourceSha256,
422
+ bmad_story_id: story.id,
423
+ target_repository: story.targetRepository,
424
+ card_sha256: sha256(card),
425
+ required_gate_ids: normalizeList(requiredGates, 'requiredGates'),
426
+ depends_on: normalizeList(dependsOn, 'dependsOn'),
427
+ imported_at: capturedAt ?? new Date().toISOString()
428
+ };
429
+ return { story, card, provenance };
430
+ }
431
+
432
+ async function pathExists(target) {
433
+ try { await access(target); return true; } catch { return false; }
434
+ }
435
+
436
+ export async function writeImportedCard({ sourcePath, outputPath, provenancePath = null, targetRepository = null, requiredGates = [], dependsOn = [], capturedAt = null } = {}) {
437
+ if (!outputPath || typeof outputPath !== 'string') throw new BmadStoryImportError('bmad_story_unmappable', 'Provide an output path for the generated Triad Card.');
438
+ const output = resolve(outputPath);
439
+ const provenance = resolve(provenancePath || `${output}.bmad-provenance.json`);
440
+ if (output === provenance) throw new BmadStoryImportError('bmad_story_unmappable', 'Card output and provenance output must be different paths.');
441
+ if (await pathExists(output) || await pathExists(provenance)) {
442
+ throw new BmadStoryImportError('bmad_story_unmappable', 'Refusing to overwrite an existing Card or provenance record.', { output, provenance });
443
+ }
444
+ const result = await importBmadStory({ sourcePath, targetRepository, requiredGates, dependsOn, capturedAt });
445
+ await mkdir(dirname(output), { recursive: true });
446
+ await mkdir(dirname(provenance), { recursive: true });
447
+ const outputTemp = `${output}.tmp-${process.pid}`;
448
+ const provenanceTemp = `${provenance}.tmp-${process.pid}`;
449
+ let outputWritten = false;
450
+ let provenanceWritten = false;
451
+ try {
452
+ await writeFile(outputTemp, result.card, { encoding: 'utf8', flag: 'wx' });
453
+ await writeFile(provenanceTemp, `${JSON.stringify({ ...result.provenance, card_path: output }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
454
+ await rename(outputTemp, output);
455
+ outputWritten = true;
456
+ await rename(provenanceTemp, provenance);
457
+ provenanceWritten = true;
458
+ } catch (error) {
459
+ await rm(outputTemp, { force: true });
460
+ await rm(provenanceTemp, { force: true });
461
+ if (outputWritten) await rm(output, { force: true });
462
+ if (provenanceWritten) await rm(provenance, { force: true });
463
+ throw new BmadStoryImportError('bmad_story_unmappable', `Could not write imported Card: ${error.message}`, { cause: error.code ?? 'write_failed' });
464
+ }
465
+ return { ...result, outputPath: output, provenancePath: provenance };
466
+ }
467
+
468
+ export const bmadStoryImporter = {
469
+ sourceKind: SOURCE_KIND,
470
+ readyStatus: READY_STATUS,
471
+ parse: parseBmadStory,
472
+ buildCard: buildTriadCard,
473
+ import: importBmadStory,
474
+ write: writeImportedCard
475
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "A lightweight, evidence-backed engineering loop for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=20"
29
29
  },
30
30
  "scripts": {
31
- "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs",
31
+ "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/immutable-quality-contract-test.mjs",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {