triad-plus 1.6.0 → 1.7.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 — 2026-09-10
4
+
5
+ - Add an optional deterministic importer for one BMAD `ready-for-dev` Story at
6
+ a time, preserving the normal Triad Card contract and source provenance.
7
+
3
8
  ## 1.6.0 — 2026-09-05
4
9
 
5
10
  - Add card-declared repository `required_gates` with additive per-card gate
package/README.md CHANGED
@@ -107,6 +107,23 @@ The Orchestrator first shows the feature cards, then delegates the bounded work.
107
107
  If a tutorial step is unclear, see the [OpenCode guide](docs/runtimes.md#opencode)
108
108
  and [troubleshooting](docs/troubleshooting.md).
109
109
 
110
+ ## Optional BMAD Story import
111
+
112
+ When BMAD planning has already produced a Story with `status: ready-for-dev`,
113
+ you can convert one Story at a time into the normal Triad Card contract:
114
+
115
+ ```bash
116
+ npx triad-plus import-bmad-story \
117
+ --source /absolute/path/to/story.md \
118
+ --output /absolute/path/to/control/features/STORY-001.md
119
+ ```
120
+
121
+ The importer is read-only and fail-closed. It preserves the Story's executable
122
+ intent, acceptance criteria, technical context, and references, while any
123
+ required gates or dependencies remain explicit caller options. It does not run
124
+ BMAD workflows or add BMAD semantics to the Core. See the [BMAD integration
125
+ guide](docs/bmad-integration.md).
126
+
110
127
  ## Quick start for every runtime
111
128
 
112
129
  Requirements: Node.js 20+ and one supported coding-agent host.
package/bin/triad-plus.js CHANGED
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from 'node:path';
8
8
  import process from 'node:process';
9
9
  import { createInterface } from 'node:readline/promises';
10
10
  import { getAdapter, listAdapters, roleDefinitions, sharedSkillNames } from '../adapters/registry.mjs';
11
+ import { writeImportedCard } from '../integrations/bmad/story-importer.mjs';
11
12
 
12
13
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
14
 
@@ -20,6 +21,7 @@ Usage:
20
21
  npx triad-plus init --host <adapter-id> --control <path> [--global] [--team-config <path>] [--allow-product-repo]
21
22
  npx triad-plus doctor --host <adapter-id> --control <path> [--hook-config <path>]
22
23
  npx triad-plus upgrade --host <adapter-id> --control <path> [--global] [--apply]
24
+ npx triad-plus import-bmad-story --source <story.md> --output <card.md> [--target-repository <id>] [--provenance <record.json>] [--required-gate <id>] [--depends-on <card-id>]
23
25
 
24
26
  Adapters: ${listAdapters().map((adapter) => adapter.id).join(', ')}
25
27
 
@@ -31,13 +33,20 @@ Installation refuses every asset overwrite. Upgrade is a dry run unless --apply
31
33
 
32
34
  function parseArgs(args) {
33
35
  const [command, ...rest] = args;
34
- const options = { command, global: false, allowProductRepo: false, apply: false };
36
+ const options = { command, global: false, allowProductRepo: false, apply: false, requiredGates: [], dependsOn: [] };
35
37
  for (let index = 0; index < rest.length; index += 1) {
36
38
  const argument = rest[index];
37
39
  if (argument === '--global') options.global = true;
38
40
  else if (argument === '--allow-product-repo') options.allowProductRepo = true;
39
41
  else if (argument === '--apply') options.apply = true;
40
- else if (['--host', '--control', '--team-config', '--hook-config'].includes(argument)) {
42
+ else if (argument === '--required-gate' || argument === '--depends-on') {
43
+ const value = rest[index + 1];
44
+ if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value.`);
45
+ const target = argument === '--required-gate' ? options.requiredGates : options.dependsOn;
46
+ target.push(value);
47
+ index += 1;
48
+ }
49
+ else if (['--host', '--control', '--team-config', '--hook-config', '--source', '--output', '--target-repository', '--provenance'].includes(argument)) {
41
50
  const value = rest[index + 1];
42
51
  if (!value || value.startsWith('--')) throw new Error(`${argument} requires a value.`);
43
52
  options[argument.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
@@ -395,6 +404,21 @@ async function upgrade(options) {
395
404
  if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
396
405
  }
397
406
 
407
+ async function importBmadStory(options) {
408
+ if (!options.source) throw new Error('Provide --source <bmad-story.md>.');
409
+ if (!options.output) throw new Error('Provide --output <triad-card.md>.');
410
+ const result = await writeImportedCard({
411
+ sourcePath: options.source,
412
+ outputPath: options.output,
413
+ targetRepository: options.targetRepository,
414
+ provenancePath: options.provenance,
415
+ requiredGates: options.requiredGates,
416
+ dependsOn: options.dependsOn
417
+ });
418
+ process.stdout.write(`Imported BMAD Story ${result.story.id} as Triad Card ${result.outputPath}\n`);
419
+ process.stdout.write(`Provenance ${result.provenancePath}\n`);
420
+ }
421
+
398
422
  async function doctor(options) {
399
423
  if (!options.control) throw new Error('Provide --control <project-control-path>.');
400
424
  const controlRoot = resolve(options.control);
@@ -468,10 +492,11 @@ try {
468
492
  if (options.command === 'init') await init(options);
469
493
  else if (options.command === 'doctor') await doctor(options);
470
494
  else if (options.command === 'upgrade') await upgrade(options);
495
+ else if (options.command === 'import-bmad-story') await importBmadStory(options);
471
496
  else if (!options.command) await interactiveInit();
472
497
  else if (options.command === '--help' || options.command === '-h') usage(0);
473
498
  else throw new Error(`Unknown command: ${options.command}`);
474
499
  } catch (error) {
475
- process.stderr.write(`${error.message}\n`);
500
+ process.stderr.write(`${error.code ? `${error.code}: ` : ''}${error.message}\n`);
476
501
  usage(2);
477
502
  }
@@ -0,0 +1,110 @@
1
+ # Optional BMAD Story integration
2
+
3
+ Triad+ can consume one already-produced BMAD Story and turn it into a normal
4
+ Triad feature Card. This is an integration boundary, not a BMAD execution
5
+ adapter: BMAD remains the planning authority and Triad remains responsible for
6
+ implementation, verification, review, and delivery.
7
+
8
+ ## Contract
9
+
10
+ The source is a read-only Markdown Story. It must contain:
11
+
12
+ - a unique Story `id` and `title` (frontmatter, metadata labels, or a Story
13
+ heading);
14
+ - `status: ready-for-dev` (frontmatter or a `Status` field);
15
+ - a target repository (or an explicit `--target-repository` importer option);
16
+ - an intent/outcome; and
17
+ - acceptance criteria.
18
+
19
+ The importer also carries through the Story's `Tasks & Acceptance`, Code Map,
20
+ Design Notes/constraints, verification expectations, and source references
21
+ when they are present. It does not interpret prose with an LLM, re-decompose a
22
+ Story, or invoke BMAD Build, Build Auto, or `bmad-loop`.
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ npx triad-plus import-bmad-story \
28
+ --source /absolute/path/to/story.md \
29
+ --output /absolute/path/to/control/features/JFR-001.md \
30
+ --target-repository webup \
31
+ --required-gate cypress-jfr \
32
+ --depends-on JFR-000
33
+ ```
34
+
35
+ `--required-gate` and `--depends-on` may be repeated. They are explicit caller
36
+ options: gate IDs are additive to the repository's globally required gates,
37
+ and dependencies are never inferred from `stories.yaml` order. Omit both when
38
+ the Card should use the repository's normal/baseline behavior.
39
+
40
+ The command writes the Card and a sidecar provenance record (by default
41
+ `<card>.bmad-provenance.json`). The provenance records `source_kind:
42
+ bmad-story`, the resolved source path, source SHA-256, BMAD Story ID, target
43
+ repository, Card SHA-256, and the explicit options used for the import.
44
+
45
+ The same operation is available to Node consumers:
46
+
47
+ ```js
48
+ import { importBmadStory, writeImportedCard } from
49
+ 'triad-plus/integrations/bmad/story-importer.mjs';
50
+
51
+ const result = await importBmadStory({
52
+ sourcePath: '/absolute/path/to/story.md',
53
+ targetRepository: 'webup',
54
+ requiredGates: ['cypress-jfr']
55
+ });
56
+
57
+ await writeImportedCard({
58
+ sourcePath: '/absolute/path/to/story.md',
59
+ outputPath: '/absolute/path/to/control/features/JFR-001.md',
60
+ targetRepository: 'webup',
61
+ requiredGates: ['cypress-jfr']
62
+ });
63
+ ```
64
+
65
+ ## Example mapping
66
+
67
+ Input (abridged):
68
+
69
+ ```md
70
+ ---
71
+ id: JFR-001
72
+ title: Route the provider document
73
+ status: ready-for-dev
74
+ target_repository: webup
75
+ ---
76
+
77
+ # Story JFR-001: Route the provider document
78
+
79
+ ## Intent
80
+
81
+ Webup renders the provider-owned document at the existing boundary.
82
+
83
+ ## Acceptance Criteria
84
+
85
+ - Given a valid document, when the route is requested, then it renders.
86
+
87
+ ## Code Map
88
+
89
+ - `src/components/jfr/`
90
+ ```
91
+
92
+ The generated Card keeps that intent, acceptance criterion, Code Map, and the
93
+ target repository, then adds the normal Triad sections and the integration
94
+ boundary note. The caller-supplied `cypress-jfr` (if any) is recorded as an
95
+ additive required gate; it is not inferred from the word "render" or from a
96
+ file extension.
97
+
98
+ ## Fail-closed behavior
99
+
100
+ No executable Card is produced for a missing source, missing/non-`ready-for-dev`
101
+ status, malformed or ambiguous ID/title, missing indispensable Card fields,
102
+ conflicting target repository, invalid caller options, or a source that changes
103
+ while it is being read. The API exposes integration-level error codes such as
104
+ `bmad_story_not_ready`, `bmad_story_ambiguous`, `bmad_story_unmappable`, and
105
+ `bmad_story_source_mutated` so a planning gap can return upstream rather than
106
+ become a Developer decision.
107
+
108
+ After import, the generated Card goes through the existing assignment and
109
+ `triad-verify` path. No Core schema, lifecycle, Reviewer, retry, or gate
110
+ execution semantics are changed by this integration.
@@ -0,0 +1,12 @@
1
+ # BMAD Story importer
2
+
3
+ This optional integration converts one BMAD Markdown Story with
4
+ `status: ready-for-dev` into a normal Triad feature Card. It is intentionally
5
+ small and deterministic: the source is read-only, caller options are explicit,
6
+ and BMAD workflows are never invoked.
7
+
8
+ See [the public BMAD integration guide](../../docs/bmad-integration.md) for the
9
+ mapping contract, CLI/API examples, provenance sidecar, and fail-closed rules.
10
+
11
+ The implementation is in `story-importer.mjs`. It does not add BMAD-specific
12
+ branches to the Triad Core or infer gates/dependencies from planning order.
@@ -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.7.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",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {