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 +5 -0
- package/README.md +17 -0
- package/bin/triad-plus.js +28 -3
- package/docs/bmad-integration.md +110 -0
- package/integrations/bmad/README.md +12 -0
- package/integrations/bmad/story-importer.mjs +475 -0
- package/package.json +2 -2
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 (
|
|
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.
|
|
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": {
|