bmad-kanban 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tthil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # bmad-kanban
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A read-only Kanban board for [BMAD Method](https://github.com/bmad-code-org/BMAD-METHOD) projects. It runs locally, reads your project's BMAD files, and shows stories as cards in Backlog, Ready, In progress, Review and Done columns, with optional epic swimlanes. It updates live when files change.
4
+
5
+ It never writes to your project. Remove a project from the sidebar and only the board's own list changes; your folder is not touched.
6
+
7
+ ## Usage
8
+
9
+ Run it inside a BMAD project:
10
+
11
+ ```sh
12
+ npx bmad-kanban
13
+ ```
14
+
15
+ Or point it at a folder:
16
+
17
+ ```sh
18
+ npx bmad-kanban ~/code/my-project
19
+ ```
20
+
21
+ Options (`bmad-kanban --help`):
22
+
23
+ - `--port N`: port to listen on (default 4310)
24
+ - `--no-open`: do not open a browser
25
+ - `[path]`: project folder (default: current directory)
26
+
27
+ If a board is already running, the new project is added to it instead of starting a second server.
28
+
29
+ Requirements: Node 22 or newer. `uv` is needed for full BMAD v7 fidelity (the board calls v7's own scripts through it). Without `uv` the board falls back to reading the files directly and shows a "reduced fidelity" notice.
30
+
31
+ The project list is stored in a per-user config directory. Set `BMAD_KANBAN_CONFIG_DIR` to use a different one. Use the × next to a project in the sidebar to remove it from the list.
32
+
33
+ ## Supported layouts
34
+
35
+ - **v6 sprint**: `sprint-status.yaml` plus story files in the implementation artifacts folder.
36
+ - **v6 spec**: story files with a `status` in their frontmatter.
37
+ - **v7**: initiatives and tickets (`tickets.toml` and story plan files).
38
+
39
+ A project can have more than one layout (typically mid-migration). Each source gets its own sidebar entry and nothing is merged across them.
40
+
41
+ ## Columns
42
+
43
+ | Column | v6 sprint | v6 spec (frontmatter) | v7 `status` |
44
+ |---|---|---|---|
45
+ | Backlog | `backlog`, no status | none, `draft`, `backlog` | none, `draft` |
46
+ | Ready | `ready-for-dev` (+ legacy `drafted`) | `ready-for-dev` | `ready-for-dev` |
47
+ | In progress | `in-progress` (+ legacy `contexted`) | `in-progress` | `in-progress`, `blocked` (red marker) |
48
+ | Review | `review` | `review`, `in-review` | `in-review`, `built` |
49
+ | Done | `done` | `done` | `done` |
50
+ | (hidden) | none | none | `dropped`, behind a "show dropped" switch |
51
+
52
+ An unknown status goes to Backlog with an "unknown status: X" badge.
53
+
54
+ ## Development
55
+
56
+ ```sh
57
+ npm install
58
+ npm test # unit tests (vitest)
59
+ npm run typecheck
60
+ npm run build # dist/cli.js and dist/web
61
+ npm run test:e2e # Playwright smoke test; run the build first
62
+ ```
63
+
64
+ ## Acknowledgements
65
+
66
+ Inspired by [vscode-bmad-kanban](https://github.com/RianKhanafi/vscode-bmad-kanban) by RianKhanafi.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseToml } from 'smol-toml';
4
+ import { parse as parseYaml } from 'yaml';
5
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ export function findProjectRoot(start) {
7
+ let dir = path.resolve(start);
8
+ for (;;) {
9
+ try {
10
+ if (fs.statSync(path.join(dir, '_bmad')).isDirectory())
11
+ return dir;
12
+ }
13
+ catch {
14
+ // not here, keep walking up
15
+ }
16
+ const parent = path.dirname(dir);
17
+ if (parent === dir)
18
+ return null;
19
+ dir = parent;
20
+ }
21
+ }
22
+ function readIf(file) {
23
+ try {
24
+ return fs.readFileSync(file, 'utf8');
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function deepMerge(a, b) {
31
+ const out = { ...a };
32
+ for (const [k, v] of Object.entries(b)) {
33
+ out[k] = isObj(v) && isObj(out[k]) ? deepMerge(out[k], v) : v;
34
+ }
35
+ return out;
36
+ }
37
+ function get(o, ...keys) {
38
+ let cur = o;
39
+ for (const k of keys) {
40
+ if (!isObj(cur) || !Object.hasOwn(cur, k))
41
+ return undefined;
42
+ cur = cur[k];
43
+ }
44
+ return cur;
45
+ }
46
+ const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
47
+ function readVersion(bmad) {
48
+ const text = readIf(path.join(bmad, '_config', 'manifest.yaml'));
49
+ if (text === null)
50
+ return null;
51
+ try {
52
+ const v = get(parseYaml(text), 'installation', 'version');
53
+ return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ export function loadBmadConfig(root) {
60
+ const absRoot = path.resolve(root);
61
+ const bmad = path.join(absRoot, '_bmad');
62
+ const tomlFiles = ['config.toml', 'config.user.toml', 'custom/config.toml', 'custom/config.user.toml'];
63
+ let merged = {};
64
+ let anyToml = false;
65
+ for (const f of tomlFiles) {
66
+ const text = readIf(path.join(bmad, f));
67
+ if (text === null)
68
+ continue;
69
+ anyToml = true;
70
+ merged = deepMerge(merged, parseToml(text));
71
+ }
72
+ let outRaw, planRaw, implRaw, initRaw;
73
+ if (anyToml) {
74
+ outRaw = get(merged, 'core', 'output_folder');
75
+ initRaw = get(merged, 'core', 'active_initiative');
76
+ planRaw = get(merged, 'modules', 'bmm', 'planning_artifacts');
77
+ implRaw = get(merged, 'modules', 'bmm', 'implementation_artifacts');
78
+ }
79
+ else {
80
+ let y = {};
81
+ for (const f of ['core/config.yaml', 'bmm/config.yaml']) {
82
+ const text = readIf(path.join(bmad, f));
83
+ if (text === null)
84
+ continue;
85
+ const parsed = parseYaml(text);
86
+ if (isObj(parsed))
87
+ y = deepMerge(y, parsed);
88
+ }
89
+ outRaw = y.output_folder;
90
+ initRaw = y.active_initiative;
91
+ planRaw = y.planning_artifacts;
92
+ implRaw = y.implementation_artifacts;
93
+ }
94
+ const resolve = (v, fallback) => {
95
+ const s = str(v);
96
+ return s === null ? fallback : path.resolve(absRoot, s.split('{project-root}').join(absRoot));
97
+ };
98
+ const outputFolder = resolve(outRaw, path.join(absRoot, '_bmad-output'));
99
+ return {
100
+ root: absRoot,
101
+ outputFolder,
102
+ planningArtifacts: resolve(planRaw, path.join(outputFolder, 'planning-artifacts')),
103
+ implementationArtifacts: resolve(implRaw, path.join(outputFolder, 'implementation-artifacts')),
104
+ activeInitiative: str(initRaw),
105
+ version: readVersion(bmad),
106
+ };
107
+ }
@@ -0,0 +1,26 @@
1
+ import { parse } from 'yaml';
2
+ export class FrontmatterError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'FrontmatterError';
6
+ }
7
+ }
8
+ const FM = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
9
+ export function readFrontmatter(text) {
10
+ const m = FM.exec(text);
11
+ if (!m)
12
+ return { data: {}, body: text };
13
+ let parsed;
14
+ try {
15
+ parsed = parse(m[1]);
16
+ }
17
+ catch (e) {
18
+ throw new FrontmatterError(`Invalid YAML frontmatter: ${e.message}`);
19
+ }
20
+ if (parsed == null)
21
+ parsed = {};
22
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
23
+ throw new FrontmatterError('Frontmatter must be a YAML mapping');
24
+ }
25
+ return { data: parsed, body: text.slice(m[0].length) };
26
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseArgs } from 'node:util';
6
+ import openBrowser from 'open';
7
+ import { findProjectRoot } from './bmad/config.js';
8
+ import { findRunning, removeLock, writeLock } from './instance.js';
9
+ import { configDir, Registry } from './registry.js';
10
+ import { startServer } from './server/app.js';
11
+ const defaultDeps = {
12
+ open: (url) => openBrowser(url),
13
+ startServer,
14
+ stdout: (s) => void process.stdout.write(s),
15
+ stderr: (s) => void process.stderr.write(s),
16
+ };
17
+ const HELP = `Usage: bmad-kanban [path] [--port N] [--no-open]
18
+
19
+ Read-only Kanban board for BMAD Method projects.
20
+
21
+ path project folder (default: current directory)
22
+ --port N port to listen on (default: 4310)
23
+ --no-open do not open a browser
24
+ --help show this help
25
+ --version show the version
26
+ `;
27
+ function version() {
28
+ const pkg = new URL('../package.json', import.meta.url);
29
+ return JSON.parse(fs.readFileSync(pkg, 'utf8')).version;
30
+ }
31
+ export async function main(argv, deps = {}) {
32
+ const d = { ...defaultDeps, ...deps };
33
+ let parsed;
34
+ try {
35
+ parsed = parseArgs({
36
+ args: argv,
37
+ allowPositionals: true,
38
+ options: {
39
+ port: { type: 'string' },
40
+ 'no-open': { type: 'boolean' },
41
+ help: { type: 'boolean' },
42
+ version: { type: 'boolean' },
43
+ },
44
+ });
45
+ }
46
+ catch (e) {
47
+ d.stderr(`${e.message}\n${HELP}`);
48
+ return 1;
49
+ }
50
+ const { values, positionals } = parsed;
51
+ if (values.help)
52
+ return d.stdout(HELP), 0;
53
+ if (values.version)
54
+ return d.stdout(`${version()}\n`), 0;
55
+ const wanted = values.port === undefined ? 4310 : Number(values.port);
56
+ if (!Number.isInteger(wanted) || wanted < 0 || wanted > 65535) {
57
+ d.stderr(`Invalid port: ${values.port}\n`);
58
+ return 1;
59
+ }
60
+ if (positionals.length > 1) {
61
+ d.stderr(`Expected at most one path argument\n${HELP}`);
62
+ return 1;
63
+ }
64
+ const given = path.resolve(positionals[0] ?? process.cwd());
65
+ const root = findProjectRoot(given);
66
+ if (positionals[0] !== undefined && !root) {
67
+ d.stderr(`Not a BMAD project: ${given}\n`);
68
+ return 1;
69
+ }
70
+ const lockFile = path.join(configDir(), 'server.json');
71
+ let port = await findRunning(lockFile);
72
+ const reuse = port !== null;
73
+ if (port === null) {
74
+ const webRoot = fileURLToPath(new URL('./web/', import.meta.url));
75
+ const server = await d.startServer({ registry: new Registry(), port: wanted, webRoot });
76
+ port = server.port;
77
+ writeLock(lockFile, { port, pid: process.pid });
78
+ const cleanup = () => removeLock(lockFile, process.pid);
79
+ process.once('exit', cleanup);
80
+ for (const sig of ['SIGINT', 'SIGTERM']) {
81
+ process.once(sig, () => {
82
+ cleanup();
83
+ void server.close().finally(() => process.exit(0));
84
+ });
85
+ }
86
+ }
87
+ const base = `http://127.0.0.1:${port}/`;
88
+ let url = base;
89
+ let name = '';
90
+ if (root) {
91
+ const res = await fetch(`${base}api/projects`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ path: root }),
95
+ });
96
+ const body = (await res.json().catch(() => ({})));
97
+ if (!res.ok || !body.id) {
98
+ d.stderr(`Could not add project: ${body.error ?? res.statusText}\n`);
99
+ return 1;
100
+ }
101
+ url = `${base}?project=${body.id}`;
102
+ name = body.name ?? path.basename(root);
103
+ }
104
+ if (!values['no-open'])
105
+ await d.open(url);
106
+ if (reuse) {
107
+ d.stdout(`Opened ${name ? `${name} in` : ''}${name ? ' ' : ''}the running board at ${url}\n`);
108
+ }
109
+ else {
110
+ d.stdout(`BMAD Kanban running at ${base}\n`);
111
+ }
112
+ return 0;
113
+ }
114
+ const self = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
115
+ if (self === fileURLToPath(import.meta.url)) {
116
+ main(process.argv.slice(2)).then((code) => {
117
+ if (code !== 0)
118
+ process.exit(code);
119
+ }, (e) => {
120
+ process.stderr.write(`${e.message}\n`);
121
+ process.exit(1);
122
+ });
123
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function readLock(file) {
4
+ try {
5
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'));
6
+ if (Number.isInteger(v?.port) && Number.isInteger(v?.pid))
7
+ return { port: v.port, pid: v.pid };
8
+ }
9
+ catch {
10
+ // missing or corrupt
11
+ }
12
+ return null;
13
+ }
14
+ export function writeLock(file, lock) {
15
+ fs.mkdirSync(path.dirname(file), { recursive: true });
16
+ const tmp = `${file}.${process.pid}.tmp`;
17
+ fs.writeFileSync(tmp, JSON.stringify(lock));
18
+ fs.renameSync(tmp, file);
19
+ }
20
+ /** Remove the lock only if it still belongs to `pid`. */
21
+ export function removeLock(file, pid) {
22
+ if (readLock(file)?.pid !== pid)
23
+ return;
24
+ try {
25
+ fs.rmSync(file, { force: true });
26
+ }
27
+ catch {
28
+ // best effort
29
+ }
30
+ }
31
+ function pidAlive(pid) {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch (e) {
37
+ return e.code === 'EPERM';
38
+ }
39
+ }
40
+ async function answers(port) {
41
+ try {
42
+ const res = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1000) });
43
+ await res.arrayBuffer();
44
+ return res.status === 200;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ export async function findRunning(file) {
51
+ const lock = readLock(file);
52
+ if (!lock)
53
+ return null;
54
+ if (pidAlive(lock.pid) && (await answers(lock.port)))
55
+ return lock.port;
56
+ try {
57
+ fs.rmSync(file, { force: true });
58
+ }
59
+ catch {
60
+ // best effort
61
+ }
62
+ return null;
63
+ }
package/dist/model.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseToml } from 'smol-toml';
4
+ import { loadBmadConfig } from '../bmad/config.js';
5
+ import { detectV6Sprint, readV6Sprint, v6SprintDetailPath } from './v6Sprint.js';
6
+ import { findSpecFolders, readV6Spec, v6SpecDetailPath } from './v6Spec.js';
7
+ import { listV7Folders, v7DetailPath, v7EntryText } from './v7Tree.js';
8
+ import { readV7 } from './v7Script.js';
9
+ export class UnknownSourceError extends Error {
10
+ constructor(sourceId) {
11
+ super(`Unknown source: ${sourceId}`);
12
+ this.name = 'UnknownSourceError';
13
+ }
14
+ }
15
+ export class UnknownStoryError extends Error {
16
+ constructor(storyKey) {
17
+ super(`Unknown story: ${storyKey}`);
18
+ this.name = 'UnknownStoryError';
19
+ }
20
+ }
21
+ const message = (e) => (e instanceof Error ? e.message : String(e));
22
+ /** Child initiative slugs named by a parent's tickets.toml `[[initiative]]` tables. */
23
+ function childSlugs(folder) {
24
+ try {
25
+ const doc = parseToml(fs.readFileSync(path.join(folder, 'tickets', 'tickets.toml'), 'utf8'));
26
+ const rows = Array.isArray(doc.initiative) ? doc.initiative : [];
27
+ return rows.flatMap((r) => (typeof r === 'object' && r !== null && typeof r.slug === 'string' ? [r.slug] : []));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ function v7Kind(kind) {
34
+ return kind === 'parent' ? 'v7-parent' : kind === 'backlog' ? 'v7-backlog' : 'v7-initiative';
35
+ }
36
+ function v7Target(f) {
37
+ return f.kind === 'backlog'
38
+ ? { type: 'v7', id: 'v7-backlog', label: 'Backlog', path: f.path, folderKind: f.kind }
39
+ : { type: 'v7', id: `v7:${f.name}`, label: f.name, path: f.path, folderKind: f.kind };
40
+ }
41
+ function kindOf(t) {
42
+ return t.type === 'v7' ? v7Kind(t.folderKind) : t.type;
43
+ }
44
+ const liveCount = (b) => b.stories.filter((s) => s.column !== 'dropped').length;
45
+ function resolveTarget(cfg, sourceId) {
46
+ if (sourceId === 'v6-sprint') {
47
+ return detectV6Sprint(cfg) ? { type: 'v6-sprint', id: sourceId, label: 'v6 sprint', path: cfg.implementationArtifacts } : null;
48
+ }
49
+ if (sourceId.startsWith('v6-spec:')) {
50
+ const name = sourceId.slice('v6-spec:'.length);
51
+ const folder = findSpecFolders(cfg).find((f) => path.basename(f) === name);
52
+ return folder === undefined ? null : { type: 'v6-spec', id: sourceId, label: name, path: folder };
53
+ }
54
+ const v7 = listV7Folders(cfg);
55
+ if (sourceId === 'v7-backlog') {
56
+ const f = v7.find((x) => x.kind === 'backlog');
57
+ return f === undefined ? null : v7Target(f);
58
+ }
59
+ if (sourceId.startsWith('v7:')) {
60
+ const name = sourceId.slice(3);
61
+ const f = v7.find((x) => x.kind !== 'backlog' && x.name === name);
62
+ return f === undefined ? null : v7Target(f);
63
+ }
64
+ return null;
65
+ }
66
+ async function loadBoard(cfg, t) {
67
+ if (t.type === 'v6-sprint')
68
+ return readV6Sprint(cfg, t.id);
69
+ if (t.type === 'v6-spec')
70
+ return readV6Spec(t.path, t.id);
71
+ return readV7(cfg, t.path, t.folderKind, t.id);
72
+ }
73
+ function refFor(t, board) {
74
+ const ref = {
75
+ id: t.id,
76
+ kind: kindOf(t),
77
+ label: t.type === 'v6-spec' && board !== null ? board.label : t.label,
78
+ path: t.path,
79
+ count: 0,
80
+ };
81
+ if (board === null)
82
+ return ref;
83
+ if (board.children !== undefined) {
84
+ ref.children = board.children.map((c) => ({
85
+ id: c.sourceId,
86
+ kind: c.kind,
87
+ label: c.label,
88
+ path: path.join(path.dirname(t.path), c.sourceId.slice(3)),
89
+ count: liveCount(c),
90
+ }));
91
+ ref.count = ref.children.reduce((n, c) => n + c.count, 0);
92
+ }
93
+ else {
94
+ ref.count = liveCount(board);
95
+ }
96
+ return ref;
97
+ }
98
+ async function refOf(cfg, t) {
99
+ try {
100
+ return refFor(t, await loadBoard(cfg, t));
101
+ }
102
+ catch {
103
+ return refFor(t, null);
104
+ }
105
+ }
106
+ export async function listSources(root) {
107
+ let cfg;
108
+ try {
109
+ cfg = loadBmadConfig(root);
110
+ }
111
+ catch (e) {
112
+ return { sources: [], defaultSourceId: null, tags: [], version: null, error: message(e) };
113
+ }
114
+ const targets = [];
115
+ if (detectV6Sprint(cfg))
116
+ targets.push(resolveTarget(cfg, 'v6-sprint'));
117
+ for (const f of findSpecFolders(cfg)) {
118
+ targets.push({ type: 'v6-spec', id: `v6-spec:${path.basename(f)}`, label: path.basename(f), path: f });
119
+ }
120
+ const folders = listV7Folders(cfg);
121
+ const children = new Set(folders.filter((f) => f.kind === 'parent').flatMap((f) => childSlugs(f.path)));
122
+ for (const f of folders) {
123
+ if (f.kind !== 'backlog' && f.kind !== 'parent' && children.has(f.name))
124
+ continue;
125
+ targets.push(v7Target(f));
126
+ }
127
+ const sources = await Promise.all(targets.map((t) => refOf(cfg, t)));
128
+ const flat = sources.flatMap((s) => [s, ...(s.children ?? [])]);
129
+ const active = cfg.activeInitiative === null ? undefined : flat.find((s) => s.id === `v7:${cfg.activeInitiative}`);
130
+ const tags = [];
131
+ if (sources.some((s) => s.kind.startsWith('v6')))
132
+ tags.push('v6');
133
+ if (sources.some((s) => s.kind.startsWith('v7')))
134
+ tags.push('v7');
135
+ return { sources, defaultSourceId: active?.id ?? sources[0]?.id ?? null, tags, version: cfg.version };
136
+ }
137
+ export async function readBoard(root, sourceId) {
138
+ let cfg;
139
+ try {
140
+ cfg = loadBmadConfig(root);
141
+ }
142
+ catch {
143
+ throw new UnknownSourceError(sourceId);
144
+ }
145
+ const t = resolveTarget(cfg, sourceId);
146
+ if (t === null)
147
+ throw new UnknownSourceError(sourceId);
148
+ try {
149
+ return await loadBoard(cfg, t);
150
+ }
151
+ catch (e) {
152
+ return {
153
+ sourceId,
154
+ kind: kindOf(t),
155
+ label: t.label,
156
+ epics: [],
157
+ stories: [],
158
+ notices: [{ level: 'error', text: `Could not read this board: ${message(e)}` }],
159
+ };
160
+ }
161
+ }
162
+ function inside(root, file) {
163
+ try {
164
+ const base = fs.realpathSync(root);
165
+ const real = fs.realpathSync(file);
166
+ return real === base || real.startsWith(base + path.sep);
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ export async function getStoryDetail(root, sourceId, storyKey) {
173
+ let cfg;
174
+ try {
175
+ cfg = loadBmadConfig(root);
176
+ }
177
+ catch {
178
+ throw new UnknownSourceError(sourceId);
179
+ }
180
+ const t = resolveTarget(cfg, sourceId);
181
+ if (t === null)
182
+ throw new UnknownSourceError(sourceId);
183
+ const file = t.type === 'v6-sprint'
184
+ ? v6SprintDetailPath(cfg, storyKey)
185
+ : t.type === 'v6-spec'
186
+ ? v6SpecDetailPath(t.path, storyKey)
187
+ : v7DetailPath(t.path, storyKey);
188
+ if (file === null) {
189
+ if (t.type === 'v7') {
190
+ const text = v7EntryText(t.path, storyKey);
191
+ if (text === null)
192
+ throw new UnknownStoryError(storyKey);
193
+ return { path: null, markdown: text };
194
+ }
195
+ // a story on the board whose file hasn't been written yet is not an error
196
+ const board = await loadBoard(cfg, t);
197
+ if (!board.stories.some((s) => s.key === storyKey))
198
+ throw new UnknownStoryError(storyKey);
199
+ return { path: null, markdown: '' };
200
+ }
201
+ if (!inside(cfg.root, file))
202
+ throw new UnknownStoryError(storyKey);
203
+ return { path: file, markdown: fs.readFileSync(file, 'utf8') };
204
+ }