@kudusov.takhir/ba-toolkit 3.1.0 → 3.2.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 +38 -1
- package/COMMANDS.md +4 -2
- package/README.md +36 -28
- package/bin/ba-toolkit.js +416 -7
- package/package.json +2 -2
- package/skills/ac/SKILL.md +1 -1
- package/skills/apicontract/SKILL.md +1 -1
- package/skills/brief/SKILL.md +3 -1
- package/skills/datadict/SKILL.md +1 -1
- package/skills/discovery/SKILL.md +159 -0
- package/skills/nfr/SKILL.md +1 -1
- package/skills/principles/SKILL.md +1 -1
- package/skills/publish/SKILL.md +79 -0
- package/skills/references/closing-message.md +2 -0
- package/skills/references/interview-protocol.md +36 -18
- package/skills/references/templates/agents-template.md +3 -1
- package/skills/references/templates/discovery-template.md +63 -0
- package/skills/research/SKILL.md +1 -1
- package/skills/scenarios/SKILL.md +1 -1
- package/skills/srs/SKILL.md +1 -1
- package/skills/stories/SKILL.md +1 -1
- package/skills/usecases/SKILL.md +1 -1
- package/skills/wireframes/SKILL.md +1 -1
package/bin/ba-toolkit.js
CHANGED
|
@@ -21,7 +21,7 @@ const PKG = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'),
|
|
|
21
21
|
// All five supported agents — Claude Code, Codex CLI, Gemini CLI,
|
|
22
22
|
// Cursor, and Windsurf — load Agent Skills as direct subfolders of
|
|
23
23
|
// their skills root: `<skills-root>/<skill-name>/SKILL.md`. The toolkit
|
|
24
|
-
// installs the
|
|
24
|
+
// installs the 23 skills natively in this layout for every agent. No
|
|
25
25
|
// .mdc conversion. Confirmed against the Agent Skills documentation
|
|
26
26
|
// for each platform via ctx7 MCP / official docs.
|
|
27
27
|
//
|
|
@@ -523,6 +523,7 @@ function stringFlag(args, key) {
|
|
|
523
523
|
const KNOWN_FLAGS = new Set([
|
|
524
524
|
'name', 'slug', 'domain', 'for', 'no-install',
|
|
525
525
|
'global', 'project', 'dry-run',
|
|
526
|
+
'format', 'out',
|
|
526
527
|
'version', 'v', 'help', 'h',
|
|
527
528
|
]);
|
|
528
529
|
|
|
@@ -1008,19 +1009,22 @@ async function cmdInit(args) {
|
|
|
1008
1009
|
log(' 2. ' + bold(`cd ${outputDir}`) + ' — open your AI agent in this folder.');
|
|
1009
1010
|
log(' Each project has its own AGENTS.md, so two agent windows');
|
|
1010
1011
|
log(' can work on two different projects in the same repo.');
|
|
1011
|
-
log(' 3. Optional: run /
|
|
1012
|
+
log(' 3. Optional: run /discovery if you do not yet know what to build,');
|
|
1013
|
+
log(' or /principles to define project-wide conventions');
|
|
1012
1014
|
log(' 4. Run /brief to start the BA pipeline');
|
|
1013
1015
|
} else if (installed === false) {
|
|
1014
1016
|
log(' 1. Skill install was cancelled. To install later, run:');
|
|
1015
1017
|
log(' ' + gray(`ba-toolkit install --for ${agentId}`));
|
|
1016
1018
|
log(' 2. ' + bold(`cd ${outputDir}`) + ' and open your AI agent there.');
|
|
1017
|
-
log(' 3. Optional: run /
|
|
1019
|
+
log(' 3. Optional: run /discovery if you do not yet know what to build,');
|
|
1020
|
+
log(' or /principles to define project-wide conventions');
|
|
1018
1021
|
log(' 4. Run /brief to start the BA pipeline');
|
|
1019
1022
|
} else {
|
|
1020
1023
|
log(' 1. Install skills for your agent:');
|
|
1021
1024
|
log(' ' + gray('ba-toolkit install --for claude-code'));
|
|
1022
1025
|
log(' 2. ' + bold(`cd ${outputDir}`) + ' and open your AI agent there.');
|
|
1023
|
-
log(' 3. Optional: run /
|
|
1026
|
+
log(' 3. Optional: run /discovery if you do not yet know what to build,');
|
|
1027
|
+
log(' or /principles to define project-wide conventions');
|
|
1024
1028
|
log(' 4. Run /brief to start the BA pipeline');
|
|
1025
1029
|
}
|
|
1026
1030
|
log('');
|
|
@@ -1061,7 +1065,7 @@ function writeManifest(destDir, items) {
|
|
|
1061
1065
|
}
|
|
1062
1066
|
|
|
1063
1067
|
// Detect the v1.x install layout: every previous install path nested
|
|
1064
|
-
//
|
|
1068
|
+
// the v1.x skills under an extra `ba-toolkit/` folder, which made them
|
|
1065
1069
|
// invisible to every agent's skill loader. Returns the absolute paths
|
|
1066
1070
|
// of any legacy folders that still exist for the given agent, so the
|
|
1067
1071
|
// caller can warn the user to clean them up before installing v2.0.
|
|
@@ -1472,6 +1476,385 @@ async function cmdUninstall(args) {
|
|
|
1472
1476
|
log('');
|
|
1473
1477
|
}
|
|
1474
1478
|
|
|
1479
|
+
// --- Publish (Notion / Confluence export) ------------------------------
|
|
1480
|
+
|
|
1481
|
+
// Escape the four HTML special characters that matter inside text
|
|
1482
|
+
// content. Used by markdownToHtml everywhere user-controlled text is
|
|
1483
|
+
// emitted into an HTML attribute or body. Keep this list short and
|
|
1484
|
+
// uncontroversial — any further entity table belongs in a real HTML
|
|
1485
|
+
// library, which we deliberately avoid (zero deps).
|
|
1486
|
+
function htmlEscape(s) {
|
|
1487
|
+
return String(s)
|
|
1488
|
+
.replace(/&/g, '&')
|
|
1489
|
+
.replace(/</g, '<')
|
|
1490
|
+
.replace(/>/g, '>')
|
|
1491
|
+
.replace(/"/g, '"');
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// GitHub-style heading slug: lowercase, spaces → dashes, drop everything
|
|
1495
|
+
// that isn't a word char or dash. Used to give every heading a stable
|
|
1496
|
+
// `id` so cross-references like `02_srs.html#fr-001` resolve.
|
|
1497
|
+
function slugifyHeading(text) {
|
|
1498
|
+
return String(text)
|
|
1499
|
+
.toLowerCase()
|
|
1500
|
+
.replace(/[^\w\s-]/g, '')
|
|
1501
|
+
.trim()
|
|
1502
|
+
.replace(/\s+/g, '-');
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Convert the BA Toolkit subset of Markdown to plain HTML. Scope is
|
|
1506
|
+
// intentionally small (no nested lists, no images, no raw HTML, no
|
|
1507
|
+
// reference-style links, no footnotes) — these features don't appear in
|
|
1508
|
+
// any shipped artifact template. The aim is a converter small enough to
|
|
1509
|
+
// audit by hand and fully covered by snapshot tests.
|
|
1510
|
+
//
|
|
1511
|
+
// Block pass: walk lines, group them into blocks (paragraph, heading,
|
|
1512
|
+
// list, table, fenced code, blockquote, hr). Inline pass: rewrite
|
|
1513
|
+
// emphasis, links and code spans inside each block's text content.
|
|
1514
|
+
function markdownToHtml(src) {
|
|
1515
|
+
const lines = String(src).replace(/\r\n/g, '\n').split('\n');
|
|
1516
|
+
const out = [];
|
|
1517
|
+
let i = 0;
|
|
1518
|
+
|
|
1519
|
+
// Inline-rewrite a single line of text. Order matters: extract code
|
|
1520
|
+
// spans first so we don't touch their contents, then links, then
|
|
1521
|
+
// emphasis. Each step replaces the matched span with a placeholder,
|
|
1522
|
+
// and a final pass swaps placeholders back so emphasis inside link
|
|
1523
|
+
// text still resolves.
|
|
1524
|
+
function inline(text) {
|
|
1525
|
+
const placeholders = [];
|
|
1526
|
+
const stash = (html) => {
|
|
1527
|
+
placeholders.push(html);
|
|
1528
|
+
return `\u0000${placeholders.length - 1}\u0000`;
|
|
1529
|
+
};
|
|
1530
|
+
// Code spans `x` — stashed first so their contents are immune to
|
|
1531
|
+
// every other inline rule.
|
|
1532
|
+
text = text.replace(/`([^`\n]+)`/g, (_, code) => stash(`<code>${htmlEscape(code)}</code>`));
|
|
1533
|
+
// Links [text](url) — also stashed so the raw <a> tag survives the
|
|
1534
|
+
// final htmlEscape pass.
|
|
1535
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
|
1536
|
+
// Recursively inline-format the label so emphasis inside links works.
|
|
1537
|
+
const inner = inline(label);
|
|
1538
|
+
return stash(`<a href="${htmlEscape(url)}">${inner}</a>`);
|
|
1539
|
+
});
|
|
1540
|
+
// Bold **x** — stash, otherwise the trailing htmlEscape would
|
|
1541
|
+
// re-escape the `<strong>` tags we just emitted.
|
|
1542
|
+
text = text.replace(/\*\*([^*\n]+)\*\*/g, (_, body) => stash(`<strong>${htmlEscape(body)}</strong>`));
|
|
1543
|
+
// Italic *x* and _x_ — same stashing rule. Narrow patterns avoid
|
|
1544
|
+
// eating any leftover ** (there are none after the bold pass, but
|
|
1545
|
+
// the guard keeps the regex robust against pathological input).
|
|
1546
|
+
text = text.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, (_, pre, body) => `${pre}${stash(`<em>${htmlEscape(body)}</em>`)}`);
|
|
1547
|
+
text = text.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, (_, pre, body) => `${pre}${stash(`<em>${htmlEscape(body)}</em>`)}`);
|
|
1548
|
+
// Escape every character that survived the stashing passes. The
|
|
1549
|
+
// placeholder marker \u0000 is not in the escape list and the
|
|
1550
|
+
// ASCII digits inside the marker are also unaffected, so the swap
|
|
1551
|
+
// below still finds them.
|
|
1552
|
+
text = htmlEscape(text);
|
|
1553
|
+
// Restore placeholders.
|
|
1554
|
+
text = text.replace(/\u0000(\d+)\u0000/g, (_, idx) => placeholders[Number(idx)]);
|
|
1555
|
+
return text;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
while (i < lines.length) {
|
|
1559
|
+
const line = lines[i];
|
|
1560
|
+
|
|
1561
|
+
// Blank lines separate blocks; collapse runs of them.
|
|
1562
|
+
if (/^\s*$/.test(line)) { i++; continue; }
|
|
1563
|
+
|
|
1564
|
+
// Fenced code block ```lang
|
|
1565
|
+
const fenceMatch = /^```(\w*)\s*$/.exec(line);
|
|
1566
|
+
if (fenceMatch) {
|
|
1567
|
+
const lang = fenceMatch[1];
|
|
1568
|
+
const body = [];
|
|
1569
|
+
i++;
|
|
1570
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
|
1571
|
+
body.push(lines[i]);
|
|
1572
|
+
i++;
|
|
1573
|
+
}
|
|
1574
|
+
// Skip the closing fence (or accept EOF as the close).
|
|
1575
|
+
if (i < lines.length) i++;
|
|
1576
|
+
const cls = lang ? ` class="language-${htmlEscape(lang)}"` : '';
|
|
1577
|
+
out.push(`<pre><code${cls}>${htmlEscape(body.join('\n'))}</code></pre>`);
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Heading # … ######
|
|
1582
|
+
const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
1583
|
+
if (headingMatch) {
|
|
1584
|
+
const level = headingMatch[1].length;
|
|
1585
|
+
const text = headingMatch[2].trim();
|
|
1586
|
+
const id = slugifyHeading(text);
|
|
1587
|
+
out.push(`<h${level} id="${htmlEscape(id)}">${inline(text)}</h${level}>`);
|
|
1588
|
+
i++;
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Horizontal rule
|
|
1593
|
+
if (/^---+\s*$/.test(line)) {
|
|
1594
|
+
out.push('<hr>');
|
|
1595
|
+
i++;
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Table — header row + alignment row + body rows. We require both
|
|
1600
|
+
// the header and the alignment row to exist; otherwise treat the
|
|
1601
|
+
// line as a paragraph.
|
|
1602
|
+
if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
|
|
1603
|
+
const splitRow = (row) => row.replace(/^\||\|\s*$/g, '').split('|').map((c) => c.trim());
|
|
1604
|
+
const header = splitRow(line);
|
|
1605
|
+
i += 2; // skip header + alignment
|
|
1606
|
+
const body = [];
|
|
1607
|
+
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
|
|
1608
|
+
body.push(splitRow(lines[i]));
|
|
1609
|
+
i++;
|
|
1610
|
+
}
|
|
1611
|
+
const thead = '<thead><tr>' + header.map((c) => `<th>${inline(c)}</th>`).join('') + '</tr></thead>';
|
|
1612
|
+
const tbody = body.length > 0
|
|
1613
|
+
? '<tbody>' + body.map((row) => '<tr>' + row.map((c) => `<td>${inline(c)}</td>`).join('') + '</tr>').join('') + '</tbody>'
|
|
1614
|
+
: '';
|
|
1615
|
+
out.push(`<table>${thead}${tbody}</table>`);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Blockquote
|
|
1620
|
+
if (/^>\s?/.test(line)) {
|
|
1621
|
+
const body = [];
|
|
1622
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
1623
|
+
body.push(lines[i].replace(/^>\s?/, ''));
|
|
1624
|
+
i++;
|
|
1625
|
+
}
|
|
1626
|
+
out.push(`<blockquote><p>${inline(body.join(' '))}</p></blockquote>`);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Unordered list
|
|
1631
|
+
if (/^[-*]\s+/.test(line)) {
|
|
1632
|
+
const items = [];
|
|
1633
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
1634
|
+
items.push(lines[i].replace(/^[-*]\s+/, ''));
|
|
1635
|
+
i++;
|
|
1636
|
+
}
|
|
1637
|
+
out.push('<ul>' + items.map((it) => `<li>${inline(it)}</li>`).join('') + '</ul>');
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Ordered list
|
|
1642
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
1643
|
+
const items = [];
|
|
1644
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
1645
|
+
items.push(lines[i].replace(/^\d+\.\s+/, ''));
|
|
1646
|
+
i++;
|
|
1647
|
+
}
|
|
1648
|
+
out.push('<ol>' + items.map((it) => `<li>${inline(it)}</li>`).join('') + '</ol>');
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Default: paragraph (greedy until blank line or another block).
|
|
1653
|
+
const para = [];
|
|
1654
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i]) &&
|
|
1655
|
+
!/^```/.test(lines[i]) && !/^[-*]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) &&
|
|
1656
|
+
!/^>\s?/.test(lines[i]) && !/^---+\s*$/.test(lines[i]) &&
|
|
1657
|
+
!(/^\|.*\|\s*$/.test(lines[i]) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1]))) {
|
|
1658
|
+
para.push(lines[i]);
|
|
1659
|
+
i++;
|
|
1660
|
+
}
|
|
1661
|
+
out.push(`<p>${inline(para.join(' '))}</p>`);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
return out.join('\n');
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Match every BA Toolkit artifact filename in a project output dir:
|
|
1668
|
+
// `00_discovery_<slug>.md`, `00_principles_<slug>.md`, `01_brief_<slug>.md`,
|
|
1669
|
+
// `07a_research_<slug>.md`, `11_handoff_<slug>.md`, `00_risks_<slug>.md`,
|
|
1670
|
+
// etc. The pattern is intentionally permissive — anything that starts
|
|
1671
|
+
// with two digits (and an optional letter) followed by `_<word>_` is
|
|
1672
|
+
// in.
|
|
1673
|
+
const ARTIFACT_FILE_RE = /^\d{2}[a-z]?_[a-z]+_.*\.md$/;
|
|
1674
|
+
|
|
1675
|
+
// Numeric-aware sort: 01 < 02 < … < 07 < 07a < 08 < … < 11. Strips the
|
|
1676
|
+
// suffix letter and uses it only as a tiebreaker so `7a` always sorts
|
|
1677
|
+
// after `7` and before `8`.
|
|
1678
|
+
function compareArtifactFilenames(a, b) {
|
|
1679
|
+
const m1 = /^(\d{2})([a-z]?)/.exec(a);
|
|
1680
|
+
const m2 = /^(\d{2})([a-z]?)/.exec(b);
|
|
1681
|
+
const n1 = parseInt(m1[1], 10);
|
|
1682
|
+
const n2 = parseInt(m2[1], 10);
|
|
1683
|
+
if (n1 !== n2) return n1 - n2;
|
|
1684
|
+
if (m1[2] !== m2[2]) return m1[2] < m2[2] ? -1 : 1;
|
|
1685
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Rewrite intra-project markdown links inside an artifact body so they
|
|
1689
|
+
// survive the import. Mode is one of:
|
|
1690
|
+
// 'notion' — `[txt](02_srs_x.md#anchor)` → `[txt](./02_srs_x.md#anchor)`
|
|
1691
|
+
// Notion's bulk markdown importer resolves relative
|
|
1692
|
+
// links between files in the same import batch.
|
|
1693
|
+
// 'confluence' — `[txt](02_srs_x.md#anchor)` → `[txt](02_srs_x.html#anchor)`
|
|
1694
|
+
// HTML import expects sibling .html filenames.
|
|
1695
|
+
// External links (http, https, mailto) and anchors-only (#fr-001) pass
|
|
1696
|
+
// through unchanged.
|
|
1697
|
+
function rewriteLinks(body, mode) {
|
|
1698
|
+
return String(body).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (full, label, url) => {
|
|
1699
|
+
if (/^(https?:|mailto:|#|\/)/i.test(url)) return full;
|
|
1700
|
+
if (!/\.md(\b|$|#)/.test(url)) return full;
|
|
1701
|
+
if (mode === 'notion') {
|
|
1702
|
+
const target = url.startsWith('./') || url.startsWith('../') ? url : './' + url;
|
|
1703
|
+
return `[${label}](${target})`;
|
|
1704
|
+
}
|
|
1705
|
+
if (mode === 'confluence') {
|
|
1706
|
+
const target = url.replace(/\.md(\b|$|#)/, '.html$1');
|
|
1707
|
+
return `[${label}](${target})`;
|
|
1708
|
+
}
|
|
1709
|
+
return full;
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Strip the managed-block markers that `init` writes into AGENTS.md so
|
|
1714
|
+
// the imported page doesn't render the HTML comments as visible text in
|
|
1715
|
+
// importers that don't strip comments. Everything between the markers
|
|
1716
|
+
// (including the markers themselves) is removed.
|
|
1717
|
+
function stripManagedBlock(body) {
|
|
1718
|
+
return String(body).replace(
|
|
1719
|
+
/<!-- ba-toolkit:begin managed -->[\s\S]*?<!-- ba-toolkit:end managed -->\s*/g,
|
|
1720
|
+
'',
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async function cmdPublish(args) {
|
|
1725
|
+
const formatRaw = (stringFlag(args, 'format') || 'both').toLowerCase();
|
|
1726
|
+
const validFormats = new Set(['notion', 'confluence', 'both']);
|
|
1727
|
+
if (!validFormats.has(formatRaw)) {
|
|
1728
|
+
logError(`Unknown --format value: ${formatRaw}`);
|
|
1729
|
+
log(' Valid formats: ' + cyan('notion') + ', ' + cyan('confluence') + ', ' + cyan('both'));
|
|
1730
|
+
process.exit(1);
|
|
1731
|
+
}
|
|
1732
|
+
const dryRun = args.flags['dry-run'] === true;
|
|
1733
|
+
const cwd = process.cwd();
|
|
1734
|
+
const outDir = stringFlag(args, 'out')
|
|
1735
|
+
? path.resolve(cwd, stringFlag(args, 'out'))
|
|
1736
|
+
: path.join(cwd, 'publish');
|
|
1737
|
+
|
|
1738
|
+
const allFiles = fs.readdirSync(cwd);
|
|
1739
|
+
const artifacts = allFiles.filter((f) => ARTIFACT_FILE_RE.test(f)).sort(compareArtifactFilenames);
|
|
1740
|
+
const hasAgents = allFiles.includes('AGENTS.md');
|
|
1741
|
+
|
|
1742
|
+
if (artifacts.length === 0) {
|
|
1743
|
+
logError(`No BA Toolkit artifacts found in ${cwd}.`);
|
|
1744
|
+
log(' Run this command from inside ' + cyan('output/<slug>/') + ' after generating artifacts.');
|
|
1745
|
+
process.exit(1);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
log('');
|
|
1749
|
+
log(' ' + cyan('BA Toolkit — Publish'));
|
|
1750
|
+
log(' ' + cyan('===================='));
|
|
1751
|
+
log('');
|
|
1752
|
+
log(` source: ${cwd}`);
|
|
1753
|
+
log(` destination: ${outDir}`);
|
|
1754
|
+
log(` format: ${formatRaw}`);
|
|
1755
|
+
log(` artifacts: ${artifacts.length}${hasAgents ? ' (+ AGENTS.md)' : ''}`);
|
|
1756
|
+
if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
|
|
1757
|
+
log('');
|
|
1758
|
+
|
|
1759
|
+
const writeFile = (relPath, body) => {
|
|
1760
|
+
const abs = path.join(outDir, relPath);
|
|
1761
|
+
if (dryRun) {
|
|
1762
|
+
log(' ' + gray('would write ') + path.relative(cwd, abs));
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1766
|
+
fs.writeFileSync(abs, body);
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
// Build the ordered file list once. AGENTS.md goes first if present
|
|
1770
|
+
// so the imported workspace has project context up top.
|
|
1771
|
+
const ordered = [];
|
|
1772
|
+
if (hasAgents) ordered.push('AGENTS.md');
|
|
1773
|
+
ordered.push(...artifacts);
|
|
1774
|
+
|
|
1775
|
+
let notionCount = 0;
|
|
1776
|
+
let confluenceCount = 0;
|
|
1777
|
+
|
|
1778
|
+
if (formatRaw === 'notion' || formatRaw === 'both') {
|
|
1779
|
+
for (const filename of ordered) {
|
|
1780
|
+
let body = fs.readFileSync(path.join(cwd, filename), 'utf8');
|
|
1781
|
+
if (filename === 'AGENTS.md') body = stripManagedBlock(body);
|
|
1782
|
+
body = rewriteLinks(body, 'notion');
|
|
1783
|
+
writeFile(path.join('notion', filename), body);
|
|
1784
|
+
notionCount++;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (formatRaw === 'confluence' || formatRaw === 'both') {
|
|
1789
|
+
const indexEntries = [];
|
|
1790
|
+
for (const filename of ordered) {
|
|
1791
|
+
let body = fs.readFileSync(path.join(cwd, filename), 'utf8');
|
|
1792
|
+
if (filename === 'AGENTS.md') body = stripManagedBlock(body);
|
|
1793
|
+
body = rewriteLinks(body, 'confluence');
|
|
1794
|
+
const html = markdownToHtml(body);
|
|
1795
|
+
const htmlName = filename.replace(/\.md$/, '.html');
|
|
1796
|
+
// Per-page wrapper. No <head> styles — let Confluence's own page
|
|
1797
|
+
// styling take over after import. Title comes from the filename
|
|
1798
|
+
// so the importer has something to use.
|
|
1799
|
+
const title = filename.replace(/\.md$/, '');
|
|
1800
|
+
const page = `<!DOCTYPE html>
|
|
1801
|
+
<html lang="en">
|
|
1802
|
+
<head><meta charset="utf-8"><title>${htmlEscape(title)}</title></head>
|
|
1803
|
+
<body>
|
|
1804
|
+
${html}
|
|
1805
|
+
</body>
|
|
1806
|
+
</html>`;
|
|
1807
|
+
writeFile(path.join('confluence', htmlName), page);
|
|
1808
|
+
indexEntries.push({ htmlName, title });
|
|
1809
|
+
confluenceCount++;
|
|
1810
|
+
}
|
|
1811
|
+
// index.html — entry point for Confluence's HTML importer. Lists
|
|
1812
|
+
// every page in pipeline order with a basic style block so the
|
|
1813
|
+
// import preview is readable.
|
|
1814
|
+
const indexHtml = `<!DOCTYPE html>
|
|
1815
|
+
<html lang="en">
|
|
1816
|
+
<head>
|
|
1817
|
+
<meta charset="utf-8">
|
|
1818
|
+
<title>BA Toolkit — project artifacts</title>
|
|
1819
|
+
<style>
|
|
1820
|
+
body { font-family: -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
1821
|
+
h1 { border-bottom: 1px solid #ddd; padding-bottom: 0.3em; }
|
|
1822
|
+
ul { line-height: 1.8; }
|
|
1823
|
+
a { color: #0052cc; text-decoration: none; }
|
|
1824
|
+
a:hover { text-decoration: underline; }
|
|
1825
|
+
code { background: #f4f5f7; padding: 0.1em 0.3em; border-radius: 3px; font-family: monospace; }
|
|
1826
|
+
</style>
|
|
1827
|
+
</head>
|
|
1828
|
+
<body>
|
|
1829
|
+
<h1>BA Toolkit — project artifacts</h1>
|
|
1830
|
+
<p>Generated by <code>ba-toolkit publish</code>. Each link below becomes a page in the imported Confluence space.</p>
|
|
1831
|
+
<ul>
|
|
1832
|
+
${indexEntries.map((e) => ` <li><a href="${htmlEscape(e.htmlName)}">${htmlEscape(e.title)}</a></li>`).join('\n')}
|
|
1833
|
+
</ul>
|
|
1834
|
+
</body>
|
|
1835
|
+
</html>`;
|
|
1836
|
+
writeFile(path.join('confluence', 'index.html'), indexHtml);
|
|
1837
|
+
confluenceCount++;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
log('');
|
|
1841
|
+
if (formatRaw === 'notion' || formatRaw === 'both') {
|
|
1842
|
+
log(` ${green('✓')} Notion bundle: ${path.relative(cwd, path.join(outDir, 'notion'))}/ (${notionCount} files)`);
|
|
1843
|
+
}
|
|
1844
|
+
if (formatRaw === 'confluence' || formatRaw === 'both') {
|
|
1845
|
+
log(` ${green('✓')} Confluence bundle: ${path.relative(cwd, path.join(outDir, 'confluence'))}/ (${confluenceCount} files, including index.html)`);
|
|
1846
|
+
}
|
|
1847
|
+
log('');
|
|
1848
|
+
log(' ' + bold('Next steps:'));
|
|
1849
|
+
if (formatRaw === 'notion' || formatRaw === 'both') {
|
|
1850
|
+
log(' Notion: drag-and-drop ' + cyan(path.relative(cwd, path.join(outDir, 'notion')) + '/') + ' into "Import → Markdown & CSV" in your workspace.');
|
|
1851
|
+
}
|
|
1852
|
+
if (formatRaw === 'confluence' || formatRaw === 'both') {
|
|
1853
|
+
log(' Confluence: zip ' + cyan(path.relative(cwd, path.join(outDir, 'confluence')) + '/') + ' and upload via "Space settings → Content tools → Import → HTML".');
|
|
1854
|
+
}
|
|
1855
|
+
log('');
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1475
1858
|
function cmdHelp() {
|
|
1476
1859
|
log(`${bold('ba-toolkit')} v${PKG.version} — AI-powered Business Analyst pipeline
|
|
1477
1860
|
|
|
@@ -1497,6 +1880,12 @@ ${bold('COMMANDS')}
|
|
|
1497
1880
|
supported agent (project + global) and
|
|
1498
1881
|
report which versions are installed where.
|
|
1499
1882
|
Read-only; no flags.
|
|
1883
|
+
publish [--format <fmt>] Bundle the artifacts in the current
|
|
1884
|
+
output/<slug>/ folder into import-ready
|
|
1885
|
+
files for Notion (markdown) and Confluence
|
|
1886
|
+
(HTML). Format: notion, confluence, or
|
|
1887
|
+
both (default). No API calls, no tokens —
|
|
1888
|
+
the user runs the actual import manually.
|
|
1500
1889
|
|
|
1501
1890
|
${bold('OPTIONS')}
|
|
1502
1891
|
--name <name> init only — skip the project name prompt
|
|
@@ -1514,8 +1903,12 @@ ${bold('OPTIONS')}
|
|
|
1514
1903
|
--project install/uninstall/upgrade — target the
|
|
1515
1904
|
project-level install (default when the
|
|
1516
1905
|
agent supports it)
|
|
1517
|
-
--dry-run init/install/uninstall/upgrade —
|
|
1518
|
-
without writing or removing files
|
|
1906
|
+
--dry-run init/install/uninstall/upgrade/publish —
|
|
1907
|
+
preview without writing or removing files
|
|
1908
|
+
--format <fmt> publish only — notion, confluence, or both
|
|
1909
|
+
(default: both)
|
|
1910
|
+
--out <path> publish only — output directory for the
|
|
1911
|
+
bundles (default: ./publish/)
|
|
1519
1912
|
|
|
1520
1913
|
${bold('GENERAL OPTIONS')}
|
|
1521
1914
|
--version, -v Print version and exit
|
|
@@ -1547,6 +1940,12 @@ ${bold('EXAMPLES')}
|
|
|
1547
1940
|
# See where (and which version) BA Toolkit is installed.
|
|
1548
1941
|
ba-toolkit status
|
|
1549
1942
|
|
|
1943
|
+
# Bundle a project's artifacts for Notion + Confluence import.
|
|
1944
|
+
cd output/my-app
|
|
1945
|
+
ba-toolkit publish
|
|
1946
|
+
ba-toolkit publish --format notion --out ./share
|
|
1947
|
+
ba-toolkit publish --format confluence --dry-run
|
|
1948
|
+
|
|
1550
1949
|
${bold('LEARN MORE')}
|
|
1551
1950
|
https://github.com/TakhirKudusov/ba-toolkit
|
|
1552
1951
|
`);
|
|
@@ -1586,6 +1985,9 @@ async function main() {
|
|
|
1586
1985
|
case 'status':
|
|
1587
1986
|
cmdStatus();
|
|
1588
1987
|
break;
|
|
1988
|
+
case 'publish':
|
|
1989
|
+
await cmdPublish(args);
|
|
1990
|
+
break;
|
|
1589
1991
|
case 'help':
|
|
1590
1992
|
cmdHelp();
|
|
1591
1993
|
break;
|
|
@@ -1616,6 +2018,13 @@ module.exports = {
|
|
|
1616
2018
|
mergeAgentsMd,
|
|
1617
2019
|
menuStep,
|
|
1618
2020
|
renderMenu,
|
|
2021
|
+
markdownToHtml,
|
|
2022
|
+
htmlEscape,
|
|
2023
|
+
slugifyHeading,
|
|
2024
|
+
rewriteLinks,
|
|
2025
|
+
stripManagedBlock,
|
|
2026
|
+
compareArtifactFilenames,
|
|
2027
|
+
ARTIFACT_FILE_RE,
|
|
1619
2028
|
KNOWN_FLAGS,
|
|
1620
2029
|
DOMAINS,
|
|
1621
2030
|
AGENTS,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kudusov.takhir/ba-toolkit",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "AI-powered Business Analyst pipeline —
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "AI-powered Business Analyst pipeline — 23 skills from concept discovery to development handoff, with one-command Notion + Confluence publish. Works with Claude Code, Codex CLI, Gemini CLI, Cursor, and Windsurf.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"business-analyst",
|
|
7
7
|
"requirements",
|
package/skills/ac/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Read `references/environment.md` from the `ba-toolkit` directory to determine th
|
|
|
21
21
|
|
|
22
22
|
## Interview
|
|
23
23
|
|
|
24
|
-
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of
|
|
24
|
+
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of up to 4 domain-appropriate options plus a free-text "Other" row last (5 rows max), mark exactly one row **Recommended** based on the loaded domain reference and prior answers, render variants in the user's language (rule 11), and wait for an answer before asking the next question.
|
|
25
25
|
>
|
|
26
26
|
> **Inline context (protocol rule 9):** if the user wrote text after `/ac` (e.g., `/ac focus on US-007 and US-011`), use it as a story-id filter for which acceptance criteria to draft first.
|
|
27
27
|
|
|
@@ -21,7 +21,7 @@ Read `references/environment.md` from the `ba-toolkit` directory to determine th
|
|
|
21
21
|
|
|
22
22
|
## Interview
|
|
23
23
|
|
|
24
|
-
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of
|
|
24
|
+
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of up to 4 domain-appropriate options plus a free-text "Other" row last (5 rows max), mark exactly one row **Recommended** based on the loaded domain reference and prior answers, render variants in the user's language (rule 11), and wait for an answer before asking the next question.
|
|
25
25
|
>
|
|
26
26
|
> **Inline context (protocol rule 9):** if the user wrote text after `/apicontract` (e.g., `/apicontract REST with JWT auth, OpenAPI 3.1`), use it as a style and protocol hint for the API design.
|
|
27
27
|
|
package/skills/brief/SKILL.md
CHANGED
|
@@ -22,6 +22,8 @@ Read `references/environment.md` from the `ba-toolkit` directory to determine th
|
|
|
22
22
|
|
|
23
23
|
No prior artifacts required. If `01_brief_*.md` already exists, warn the user and offer to overwrite or create a new project.
|
|
24
24
|
|
|
25
|
+
If `00_discovery_*.md` exists in the output directory, load it as concept context. Extract the problem space (section 1), target audience hypotheses (section 2), recommended domain (section 3), MVP feature hypotheses (section 5), and the scope hint from section 8, and use them to pre-fill the structured interview questions per protocol rule 9. Skip any required topic that the discovery artifact already answers — only ask the user about gaps and open validation questions. Acknowledge the discovery hand-off in one line at the start of the interview so the user knows their concept work was loaded.
|
|
26
|
+
|
|
25
27
|
If `00_principles_*.md` exists in the output directory, load it and apply its conventions for this and all subsequent pipeline steps (artifact language, ID format, traceability requirements, Definition of Ready, quality gate threshold).
|
|
26
28
|
|
|
27
29
|
### 3. Domain selection
|
|
@@ -34,7 +36,7 @@ The domain is written into the brief metadata and passed to all subsequent pipel
|
|
|
34
36
|
|
|
35
37
|
### 4. Interview
|
|
36
38
|
|
|
37
|
-
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of
|
|
39
|
+
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of up to 4 domain-appropriate options (load `references/domains/{domain}.md` for the ones that fit) plus a free-text "Other" row last (5 rows max), mark exactly one row **Recommended** based on the loaded domain reference and prior answers, render variants in the user's language (rule 11), and wait for an answer before asking the next question.
|
|
38
40
|
>
|
|
39
41
|
> **Inline context (protocol rule 9):** if the user wrote text after `/brief` (e.g., `/brief I want to build an online store for construction materials`), parse that as the lead-in answer, acknowledge it in one line, and skip directly to the first structured question that the inline text doesn't already cover.
|
|
40
42
|
>
|
package/skills/datadict/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Read `references/environment.md` from the `ba-toolkit` directory to determine th
|
|
|
21
21
|
|
|
22
22
|
## Interview
|
|
23
23
|
|
|
24
|
-
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of
|
|
24
|
+
> **Follow the [Interview Protocol](../references/interview-protocol.md):** ask one question at a time, present a 2-column `| ID | Variant |` markdown table of up to 4 domain-appropriate options plus a free-text "Other" row last (5 rows max), mark exactly one row **Recommended** based on the loaded domain reference and prior answers, render variants in the user's language (rule 11), and wait for an answer before asking the next question.
|
|
25
25
|
>
|
|
26
26
|
> **Inline context (protocol rule 9):** if the user wrote text after `/datadict` (e.g., `/datadict the user and order entities are critical`), use it as a hint for which entities to model first.
|
|
27
27
|
|