pi-bmad-flow 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.
Files changed (43) hide show
  1. package/README.md +111 -0
  2. package/docs/install-model.md +64 -0
  3. package/docs/workflow-wireframe.md +122 -0
  4. package/extensions/bmad-orchestrator/commands.ts +238 -0
  5. package/extensions/bmad-orchestrator/detector.ts +168 -0
  6. package/extensions/bmad-orchestrator/files.ts +49 -0
  7. package/extensions/bmad-orchestrator/gates.ts +70 -0
  8. package/extensions/bmad-orchestrator/index.ts +131 -0
  9. package/extensions/bmad-orchestrator/packets.ts +172 -0
  10. package/extensions/bmad-orchestrator/router.ts +79 -0
  11. package/extensions/bmad-orchestrator/sprint.ts +82 -0
  12. package/extensions/bmad-orchestrator/tool.ts +78 -0
  13. package/extensions/bmad-orchestrator/types.ts +96 -0
  14. package/extensions/bmad-orchestrator/ui.ts +19 -0
  15. package/fixtures/sample-bmad-project/_bmad/_config/manifest.yaml +5 -0
  16. package/fixtures/sample-bmad-project/_bmad/bmm/README.md +1 -0
  17. package/fixtures/sample-bmad-project/_bmad/core/README.md +1 -0
  18. package/fixtures/sample-bmad-project/_bmad/tea/README.md +1 -0
  19. package/fixtures/sample-bmad-project/_bmad/wds/README.md +1 -0
  20. package/fixtures/sample-bmad-project/_bmad-output/implementation-artifacts/sprint-status.yaml +12 -0
  21. package/fixtures/sample-bmad-project/_bmad-output/implementation-artifacts/stories/1-2-user-authentication-dashboard.md +26 -0
  22. package/fixtures/sample-bmad-project/_bmad-output/planning-artifacts/architecture.md +7 -0
  23. package/fixtures/sample-bmad-project/_bmad-output/planning-artifacts/epic-1-auth-dashboard.md +7 -0
  24. package/fixtures/sample-bmad-project/_bmad-output/planning-artifacts/prd.md +6 -0
  25. package/fixtures/sample-bmad-project/_bmad-output/test-artifacts/nfr-assessment.md +7 -0
  26. package/fixtures/sample-bmad-project/_bmad-output/test-artifacts/test-review.md +6 -0
  27. package/fixtures/sample-bmad-project/_bmad-output/test-artifacts/traceability-matrix.md +5 -0
  28. package/fixtures/sample-bmad-project/design-artifacts/C-UX-Scenarios/01-user-onboarding/00-scenario-overview.md +5 -0
  29. package/fixtures/sample-bmad-project/design-artifacts/C-UX-Scenarios/01-user-onboarding/1.2-authentication-dashboard/Frontend/specifications.md +12 -0
  30. package/fixtures/sample-bmad-project/design-artifacts/D-Design-System/components/auth-form.md +3 -0
  31. package/fixtures/sample-bmad-project/design-artifacts/D-Design-System/components/dashboard-primary-button.md +3 -0
  32. package/fixtures/sample-bmad-project/design-artifacts/D-Design-System/design-tokens.md +5 -0
  33. package/fixtures/sample-bmad-project/design-artifacts/E-PRD/Design-Deliveries/DD-001-auth-dashboard.yaml +17 -0
  34. package/fixtures/sample-bmad-project/design-artifacts/_progress/00-design-log.md +5 -0
  35. package/fixtures/sample-bmad-project/docs/project-context.md +5 -0
  36. package/package.json +36 -0
  37. package/scripts/audit-project.mjs +266 -0
  38. package/scripts/bootstrap.mjs +108 -0
  39. package/scripts/check.mjs +64 -0
  40. package/scripts/fixture-smoke.mjs +55 -0
  41. package/skills/bmad-phase-handoff/SKILL.md +13 -0
  42. package/skills/bmad-story-full/SKILL.md +20 -0
  43. package/skills/bmad-story-lean/SKILL.md +18 -0
@@ -0,0 +1,49 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export function collectMarkdownFiles(dirPath: string): string[] {
5
+ if (!existsSync(dirPath)) return [];
6
+
7
+ const results: string[] = [];
8
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
9
+ const fullPath = join(dirPath, entry.name);
10
+ if (entry.isDirectory()) {
11
+ results.push(...collectMarkdownFiles(fullPath));
12
+ continue;
13
+ }
14
+
15
+ if (entry.isFile() && entry.name.endsWith(".md")) {
16
+ results.push(fullPath);
17
+ }
18
+ }
19
+
20
+ return results.sort();
21
+ }
22
+
23
+ export function collectYamlFiles(dirPath: string): string[] {
24
+ if (!existsSync(dirPath)) return [];
25
+
26
+ const results: string[] = [];
27
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
28
+ const fullPath = join(dirPath, entry.name);
29
+ if (entry.isDirectory()) {
30
+ results.push(...collectYamlFiles(fullPath));
31
+ continue;
32
+ }
33
+
34
+ if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) {
35
+ results.push(fullPath);
36
+ }
37
+ }
38
+
39
+ return results.sort();
40
+ }
41
+
42
+ export function isDirectory(path: string): boolean {
43
+ if (!existsSync(path)) return false;
44
+ try {
45
+ return statSync(path).isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
@@ -0,0 +1,70 @@
1
+ import type { BmadProjectState } from "./types.js";
2
+
3
+ export interface GateDecision {
4
+ level: "lightweight" | "strong";
5
+ reason: string;
6
+ commands: string[];
7
+ evidenceFiles: string[];
8
+ }
9
+
10
+ function unique<T>(values: T[]): T[] {
11
+ return Array.from(new Set(values));
12
+ }
13
+
14
+ export function decideGate(state: BmadProjectState, hasTeaModule: boolean): GateDecision {
15
+ const story = state.activeStory ?? state.nextReadyStory ?? "";
16
+ const storyLower = story.toLowerCase();
17
+ const risky = /(auth|payment|migration|security|infra|public-api|billing|permissions)/.test(storyLower);
18
+ const nfrSensitive = /(security|payment|public-api|latency|performance|scale|compliance|audit)/.test(storyLower);
19
+ const uiHeavy = state.inventory.wdsPageSpecFiles.length > 0 && /(dashboard|checkout|profile|form|modal|screen|page|ui|ux)/.test(storyLower);
20
+ const evidenceFiles = [
21
+ ...state.inventory.teaReviewFiles,
22
+ ...state.inventory.teaTraceFiles,
23
+ ...state.inventory.teaNfrFiles,
24
+ ].slice(0, 6);
25
+
26
+ if (!hasTeaModule) {
27
+ return {
28
+ level: risky ? "strong" : "lightweight",
29
+ reason: risky
30
+ ? "Story appears high risk, but TEA is not installed. Fall back to code review only."
31
+ : "Routine scope and no TEA module installed.",
32
+ commands: [`bmad-code-review ${story}`.trim()],
33
+ evidenceFiles,
34
+ };
35
+ }
36
+
37
+ if (risky || nfrSensitive) {
38
+ return {
39
+ level: "strong",
40
+ reason: nfrSensitive
41
+ ? "Story is risk-sensitive and should go through TEA review, traceability, and NFR assessment."
42
+ : "Story appears high risk and should go through TEA review and traceability.",
43
+ commands: unique(
44
+ [
45
+ `bmad-code-review ${story}`.trim(),
46
+ "bmad-testarch-test-review",
47
+ "bmad-testarch-trace",
48
+ nfrSensitive ? "bmad-testarch-nfr" : "",
49
+ ].filter(Boolean),
50
+ ),
51
+ evidenceFiles,
52
+ };
53
+ }
54
+
55
+ if (uiHeavy) {
56
+ return {
57
+ level: "lightweight",
58
+ reason: "UI-heavy story. Use TEA test review plus traceability to keep UX flows covered.",
59
+ commands: unique([`bmad-code-review ${story}`.trim(), "bmad-testarch-test-review", "bmad-testarch-trace"]),
60
+ evidenceFiles,
61
+ };
62
+ }
63
+
64
+ return {
65
+ level: "lightweight",
66
+ reason: "Routine scope; standard review plus TEA test review is sufficient.",
67
+ commands: unique([`bmad-code-review ${story}`.trim(), "bmad-testarch-test-review"]),
68
+ evidenceFiles,
69
+ };
70
+ }
@@ -0,0 +1,131 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { detectBmadInstall, detectBmadProjectState } from "./detector.js";
3
+ import { registerBmadCommands } from "./commands.js";
4
+ import { decideNextAction } from "./router.js";
5
+ import { registerBmadTool } from "./tool.js";
6
+ import { clearBmadUi, refreshBmadUi } from "./ui.js";
7
+
8
+ type BmadSessionSnapshot = {
9
+ event?: string;
10
+ storyKey?: string;
11
+ packetMode?: string;
12
+ gateLevel?: string;
13
+ nextCommand?: string;
14
+ };
15
+
16
+ function routeIntent(text: string): string | undefined {
17
+ const normalized = text.trim().toLowerCase();
18
+ if (!normalized || normalized.startsWith("/")) return undefined;
19
+
20
+ if (/^(what('?s| is) next|what should i do next|bmad next)$/.test(normalized)) return "/bmad-next";
21
+ if (/^(start (the )?next story|start next|begin next story|pick up the next story)$/.test(normalized)) return "/bmad-start";
22
+ if (/^(review this( story)?|review current story|run review)$/.test(normalized)) return "/bmad-review";
23
+ if (/^(gate this( story)?|run gate|quality gate this)$/.test(normalized)) return "/bmad-gate";
24
+ if (/^(what phase are we in|current phase|bmad phase)$/.test(normalized)) return "/bmad-phase";
25
+
26
+ return undefined;
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null;
31
+ }
32
+
33
+ function latestBmadSnapshot(entries: unknown[]): BmadSessionSnapshot | undefined {
34
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
35
+ const entry = entries[i];
36
+ if (!isRecord(entry)) continue;
37
+ if (entry.type !== "custom" || entry.customType !== "bmad-state") continue;
38
+ const data = entry.data;
39
+ if (!isRecord(data)) continue;
40
+
41
+ return {
42
+ event: typeof data.event === "string" ? data.event : undefined,
43
+ storyKey: typeof data.storyKey === "string" ? data.storyKey : undefined,
44
+ packetMode: typeof data.packetMode === "string" ? data.packetMode : undefined,
45
+ gateLevel: typeof data.gateLevel === "string" ? data.gateLevel : undefined,
46
+ nextCommand: typeof data.nextCommand === "string" ? data.nextCommand : undefined,
47
+ };
48
+ }
49
+
50
+ return undefined;
51
+ }
52
+
53
+ export default function bmadOrchestrator(pi: ExtensionAPI): void {
54
+ const cwd = process.cwd();
55
+ registerBmadCommands(pi, cwd);
56
+ registerBmadTool(pi, cwd);
57
+
58
+ pi.on("input", async (event) => {
59
+ if (event.source === "extension") return { action: "continue" };
60
+ const command = routeIntent(event.text);
61
+ if (!command) return { action: "continue" };
62
+ pi.sendUserMessage(command, { source: "extension" });
63
+ return { action: "handled" };
64
+ });
65
+
66
+ pi.on("session_start", async (_event, ctx) => {
67
+ const install = detectBmadInstall(cwd);
68
+ if (!install.installed) {
69
+ clearBmadUi(ctx);
70
+ ctx.ui.notify("pi-bmad-flow loaded. BMAD install not detected in this project.", "warning");
71
+ return;
72
+ }
73
+
74
+ const modules = install.modules.length > 0 ? install.modules.join(", ") : "none";
75
+ const state = detectBmadProjectState(cwd);
76
+ const next = decideNextAction(state);
77
+ refreshBmadUi(ctx, state, next);
78
+ ctx.ui.notify(`pi-bmad-flow ready. BMAD modules: ${modules}`, "info");
79
+ });
80
+
81
+ pi.on("agent_end", async (_event, ctx) => {
82
+ const install = detectBmadInstall(cwd);
83
+ if (!install.installed) return;
84
+ const state = detectBmadProjectState(cwd);
85
+ const next = decideNextAction(state);
86
+ refreshBmadUi(ctx, state, next);
87
+ });
88
+
89
+ pi.on("session_before_compact", async (event, ctx) => {
90
+ const install = detectBmadInstall(cwd);
91
+ if (!install.installed) return undefined;
92
+
93
+ const state = detectBmadProjectState(cwd);
94
+ const next = decideNextAction(state);
95
+ const snapshot = latestBmadSnapshot(ctx.sessionManager.getBranch() as unknown[]);
96
+
97
+ const summaryParts = [
98
+ `BMAD phase: ${state.phase}.`,
99
+ `Planning artifacts: PRD ${state.inventory.prdFiles.length}, UX ${state.inventory.uxFiles.length}, Architecture ${state.inventory.architectureFiles.length}, Epics ${state.inventory.epicFiles.length}.`,
100
+ `Delivery state: active story ${state.activeStory ?? "none"}, ready story ${state.nextReadyStory ?? "none"}.`,
101
+ `Recommended next action: ${next.command} (${next.summary}).`,
102
+ ];
103
+
104
+ if (snapshot?.storyKey) {
105
+ summaryParts.push(`Latest BMAD session event: ${snapshot.event ?? "unknown"} for story ${snapshot.storyKey}.`);
106
+ }
107
+ if (snapshot?.packetMode) {
108
+ summaryParts.push(`Current packet mode: ${snapshot.packetMode}.`);
109
+ }
110
+ if (snapshot?.gateLevel) {
111
+ summaryParts.push(`Latest gate level: ${snapshot.gateLevel}.`);
112
+ }
113
+
114
+ return {
115
+ compaction: {
116
+ summary: summaryParts.join(" "),
117
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
118
+ tokensBefore: event.preparation.tokensBefore,
119
+ details: {
120
+ bmad: {
121
+ phase: state.phase,
122
+ activeStory: state.activeStory,
123
+ nextReadyStory: state.nextReadyStory,
124
+ nextCommand: next.command,
125
+ snapshot,
126
+ },
127
+ },
128
+ },
129
+ };
130
+ });
131
+ }
@@ -0,0 +1,172 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import type { BmadProjectState, StoryPacket } from "./types.js";
3
+
4
+ const HIGH_RISK_TOKENS = ["auth", "payment", "migration", "security", "infra", "api", "billing", "permissions"];
5
+ const UI_TOKENS = ["ui", "ux", "page", "screen", "dashboard", "form", "modal", "checkout", "profile", "onboarding", "button"];
6
+ const STOP_WORDS = new Set([
7
+ "the",
8
+ "and",
9
+ "for",
10
+ "with",
11
+ "from",
12
+ "into",
13
+ "user",
14
+ "story",
15
+ "epic",
16
+ "page",
17
+ "screen",
18
+ "flow",
19
+ "this",
20
+ "that",
21
+ ]);
22
+
23
+ function tokenize(value: string): string[] {
24
+ return value
25
+ .toLowerCase()
26
+ .split(/[^a-z0-9]+/)
27
+ .filter((token) => token.length >= 3 && !STOP_WORDS.has(token));
28
+ }
29
+
30
+ function uniqueTokens(tokens: string[]): string[] {
31
+ return Array.from(new Set(tokens));
32
+ }
33
+
34
+ function overlapCount(left: string[], right: string[]): number {
35
+ const rightSet = new Set(right);
36
+ return left.filter((token) => rightSet.has(token)).length;
37
+ }
38
+
39
+ function resolveStoryFile(state: BmadProjectState, storyKey: string): string | undefined {
40
+ return state.inventory.storyFiles.find((file) => file.toLowerCase().includes(storyKey.toLowerCase()));
41
+ }
42
+
43
+ function storyContextTokens(state: BmadProjectState, storyKey: string): string[] {
44
+ const tokens = tokenize(storyKey);
45
+ const storyFile = resolveStoryFile(state, storyKey);
46
+ if (storyFile && existsSync(storyFile)) {
47
+ const content = readFileSync(storyFile, "utf8");
48
+ tokens.push(...tokenize(content));
49
+ }
50
+ return uniqueTokens(tokens);
51
+ }
52
+
53
+ function isUiRelevant(state: BmadProjectState, storyKey: string, tokens: string[]): boolean {
54
+ if (state.inventory.wdsPageSpecFiles.length === 0 && state.inventory.wdsDeliveryFiles.length === 0) {
55
+ return false;
56
+ }
57
+
58
+ if (tokens.some((token) => UI_TOKENS.includes(token))) {
59
+ return true;
60
+ }
61
+
62
+ const fileMatches = [...state.inventory.wdsPageSpecFiles, ...state.inventory.wdsDeliveryFiles].some((file) => {
63
+ const fileTokenSet = tokenize(file);
64
+ return overlapCount(tokens, fileTokenSet) > 0;
65
+ });
66
+
67
+ return fileMatches;
68
+ }
69
+
70
+ function scoreFile(state: BmadProjectState, storyKey: string, storyTokens: string[], file: string, mode: "lean" | "full"): number {
71
+ let score = overlapCount(storyTokens, tokenize(file)) * 8;
72
+
73
+ if (file.toLowerCase().includes(storyKey.toLowerCase())) score += 120;
74
+ if (state.inventory.projectContextFiles.includes(file)) score += 60;
75
+ if (state.inventory.architectureFiles.includes(file)) score += 55;
76
+ if (state.inventory.wdsDeliveryFiles.includes(file)) score += 70;
77
+ if (state.inventory.wdsPageSpecFiles.includes(file)) score += 60;
78
+ if (state.inventory.wdsScenarioFiles.includes(file)) score += 35;
79
+ if (state.inventory.wdsDesignSystemFiles.includes(file)) score += 25;
80
+ if (state.inventory.prdFiles.includes(file)) score += 20;
81
+ if (state.inventory.reviewFiles.includes(file)) score += 15;
82
+
83
+ if (mode === "full") {
84
+ if (state.inventory.teaTraceFiles.includes(file)) score += 25;
85
+ if (state.inventory.teaReviewFiles.includes(file)) score += 20;
86
+ if (state.inventory.teaNfrFiles.includes(file)) score += 15;
87
+ }
88
+
89
+ return score;
90
+ }
91
+
92
+ export function selectPacketMode(state: BmadProjectState): "lean" | "full" {
93
+ if (!state.hasImplementationArtifacts) return "full";
94
+ if (!state.nextReadyStory) return "full";
95
+ if (state.inventory.projectContextFiles.length === 0) return "full";
96
+
97
+ const key = state.nextReadyStory.toLowerCase();
98
+ if (HIGH_RISK_TOKENS.some((token) => key.includes(token))) return "full";
99
+
100
+ return "lean";
101
+ }
102
+
103
+ export function packetHint(mode: "lean" | "full"): string {
104
+ if (mode === "full") {
105
+ return "Use full packet mode: include deeper architecture and prior-story intelligence.";
106
+ }
107
+ return "Use lean packet mode: include current story, ACs, and minimal architecture context.";
108
+ }
109
+
110
+ function rankPacketFiles(state: BmadProjectState, storyKey: string, mode: "lean" | "full"): string[] {
111
+ const storyTokens = storyContextTokens(state, storyKey);
112
+ const storyMatch = resolveStoryFile(state, storyKey);
113
+ const uiRelevant = isUiRelevant(state, storyKey, storyTokens);
114
+ const baseCandidates = [
115
+ ...state.inventory.projectContextFiles,
116
+ ...state.inventory.architectureFiles,
117
+ ...state.inventory.prdFiles,
118
+ ...state.inventory.reviewFiles,
119
+ ];
120
+ const wdsCandidates = uiRelevant
121
+ ? [
122
+ ...state.inventory.wdsDeliveryFiles,
123
+ ...state.inventory.wdsPageSpecFiles,
124
+ ...state.inventory.wdsScenarioFiles,
125
+ ...state.inventory.wdsDesignSystemFiles,
126
+ ]
127
+ : [];
128
+ const teaCandidates = [...state.inventory.teaReviewFiles, ...state.inventory.teaTraceFiles, ...state.inventory.teaNfrFiles];
129
+ const ranked = [...baseCandidates, ...wdsCandidates, ...teaCandidates]
130
+ .filter((file, index, self) => self.indexOf(file) === index)
131
+ .map((file) => ({
132
+ file,
133
+ score: scoreFile(state, storyKey, storyTokens, file, mode),
134
+ }))
135
+ .filter((entry) => entry.score > 0)
136
+ .sort((left, right) => right.score - left.score)
137
+ .map((entry) => entry.file);
138
+
139
+ return storyMatch ? [storyMatch, ...ranked.filter((file) => file !== storyMatch)] : ranked;
140
+ }
141
+
142
+ export function buildStoryPacket(state: BmadProjectState, storyKey: string, mode: "lean" | "full"): StoryPacket {
143
+ const rankedFiles = rankPacketFiles(state, storyKey, mode);
144
+ const storyTokens = storyContextTokens(state, storyKey);
145
+ const uiRelevant = isUiRelevant(state, storyKey, storyTokens);
146
+ const files = mode === "lean" ? rankedFiles.slice(0, uiRelevant ? 6 : 4) : rankedFiles.slice(0, uiRelevant ? 12 : 10);
147
+ const notes: string[] = [];
148
+
149
+ if (uiRelevant) {
150
+ notes.push("This story appears UI-facing. Include WDS deliveries, page specs, and design-system references.");
151
+ }
152
+ if (state.inventory.projectContextFiles.length > 0) {
153
+ notes.push("Respect project-context conventions before changing architecture or naming patterns.");
154
+ }
155
+ if (mode === "full" && state.inventory.teaReviewFiles.length > 0) {
156
+ notes.push("Prior TEA review artifacts are available and should inform implementation and regression avoidance.");
157
+ }
158
+ if (mode === "full") {
159
+ notes.push("Full packet selected because the story is risky, early-stage, or missing stable project context.");
160
+ } else {
161
+ notes.push("Lean packet selected to minimize tokens while preserving local story fidelity.");
162
+ }
163
+
164
+ return {
165
+ storyKey,
166
+ mode,
167
+ files,
168
+ notes,
169
+ uiRelevant,
170
+ generatedAt: new Date().toISOString(),
171
+ };
172
+ }
@@ -0,0 +1,79 @@
1
+ import type { BmadProjectState } from "./types.js";
2
+
3
+ export interface NextAction {
4
+ summary: string;
5
+ command: string;
6
+ reason: string;
7
+ }
8
+
9
+ export function decideNextAction(state: BmadProjectState): NextAction {
10
+ if (state.inventory.prdFiles.length === 0) {
11
+ return {
12
+ summary: "PRD is missing.",
13
+ command: "bmad-create-prd",
14
+ reason: "Requirements need to exist before architecture, stories, or implementation can stay coherent.",
15
+ };
16
+ }
17
+
18
+ if (state.inventory.uxFiles.length === 0) {
19
+ return {
20
+ summary: "UX and WDS artifacts are missing.",
21
+ command: "bmad-create-ux-design",
22
+ reason: "Create UX direction before story breakdown so UI and flow decisions are explicit.",
23
+ };
24
+ }
25
+
26
+ if (state.inventory.architectureFiles.length === 0) {
27
+ return {
28
+ summary: "Architecture is missing.",
29
+ command: "bmad-create-architecture",
30
+ reason: "Technical decisions should be fixed before implementation planning.",
31
+ };
32
+ }
33
+
34
+ if (state.inventory.epicFiles.length === 0) {
35
+ return {
36
+ summary: "Epic and story breakdown is missing.",
37
+ command: "bmad-create-epics-and-stories",
38
+ reason: "Implementation should execute from approved stories, not raw planning docs.",
39
+ };
40
+ }
41
+
42
+ if (!state.hasSprintStatus) {
43
+ return {
44
+ summary: "Sprint tracking is not initialized.",
45
+ command: "bmad-sprint-planning",
46
+ reason: "Generate sprint-status.yaml before story execution.",
47
+ };
48
+ }
49
+
50
+ if (state.activeStory) {
51
+ return {
52
+ summary: `Story ${state.activeStory} is already in progress.`,
53
+ command: "bmad-dev-story",
54
+ reason: "Continue current implementation thread before starting new work.",
55
+ };
56
+ }
57
+
58
+ if (state.nextReadyStory) {
59
+ return {
60
+ summary: `Story ${state.nextReadyStory} is ready to start.`,
61
+ command: "/bmad-start",
62
+ reason: "Use orchestrator start flow to create isolated story execution.",
63
+ };
64
+ }
65
+
66
+ if (state.nextBacklogStory) {
67
+ return {
68
+ summary: `Backlog story ${state.nextBacklogStory} is next in queue.`,
69
+ command: "bmad-create-story",
70
+ reason: "Prepare the next story file before development starts.",
71
+ };
72
+ }
73
+
74
+ return {
75
+ summary: "No backlog or ready stories were found.",
76
+ command: "bmad-correct-course",
77
+ reason: "Re-plan or add stories before continuing.",
78
+ };
79
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import type { SprintStatus } from "./types.js";
4
+
5
+ const STORY_KEY_PATTERN = /^\d+-\d+-/;
6
+
7
+ function parseStatusLine(line: string): { key: string; value: string } | undefined {
8
+ const match = line.match(/^\s{2}([a-zA-Z0-9._-]+):\s*([a-zA-Z0-9._-]+)\s*$/);
9
+ if (!match) return undefined;
10
+ return { key: match[1], value: match[2] };
11
+ }
12
+
13
+ export function loadSprintStatus(statusPath: string): SprintStatus | undefined {
14
+ if (!existsSync(statusPath)) return undefined;
15
+ const content = readFileSync(statusPath, "utf8");
16
+ const lines = content.split(/\r?\n/);
17
+
18
+ const developmentStatus: Record<string, string> = {};
19
+ const storyOrder: string[] = [];
20
+ let storyLocation: string | undefined;
21
+ let inDevelopmentStatus = false;
22
+
23
+ for (const rawLine of lines) {
24
+ const line = rawLine.trimEnd();
25
+ if (!inDevelopmentStatus && !storyLocation) {
26
+ const storyLocationMatch = line.match(/^story_location:\s*["']?(.+?)["']?\s*$/);
27
+ if (storyLocationMatch) {
28
+ storyLocation = storyLocationMatch[1];
29
+ }
30
+ }
31
+
32
+ if (!inDevelopmentStatus) {
33
+ if (line === "development_status:") inDevelopmentStatus = true;
34
+ continue;
35
+ }
36
+
37
+ if (!line.startsWith(" ") && line.length > 0 && !line.startsWith("#")) break;
38
+ const parsed = parseStatusLine(line);
39
+ if (!parsed) continue;
40
+
41
+ developmentStatus[parsed.key] = parsed.value;
42
+ if (STORY_KEY_PATTERN.test(parsed.key)) storyOrder.push(parsed.key);
43
+ }
44
+
45
+ return { developmentStatus, storyOrder, storyLocation };
46
+ }
47
+
48
+ export function updateStoryStatus(statusPath: string, storyKey: string, nextStatus: string): boolean {
49
+ if (!existsSync(statusPath)) return false;
50
+ const content = readFileSync(statusPath, "utf8");
51
+ const lines = content.split(/\r?\n/);
52
+
53
+ let changed = false;
54
+ let inDevelopmentStatus = false;
55
+
56
+ for (let i = 0; i < lines.length; i += 1) {
57
+ const line = lines[i];
58
+ if (!inDevelopmentStatus) {
59
+ if (line.trim() === "development_status:") inDevelopmentStatus = true;
60
+ continue;
61
+ }
62
+
63
+ if (!line.startsWith(" ") && line.trim() !== "") break;
64
+ const parsed = parseStatusLine(line);
65
+ if (!parsed || parsed.key !== storyKey) continue;
66
+ lines[i] = ` ${storyKey}: ${nextStatus}`;
67
+ changed = true;
68
+ }
69
+
70
+ if (!changed) return false;
71
+ writeFileSync(statusPath, `${lines.join("\n")}\n`, "utf8");
72
+ return true;
73
+ }
74
+
75
+ export function resolveStoryLocation(statusPath: string, sprint?: SprintStatus): string {
76
+ const fallbackDir = dirname(statusPath);
77
+ const location = sprint?.storyLocation?.trim();
78
+ if (!location) return fallbackDir;
79
+ if (location.startsWith("{")) return fallbackDir;
80
+ if (location.startsWith("/")) return location;
81
+ return resolve(dirname(statusPath), location);
82
+ }
@@ -0,0 +1,78 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { detectBmadInstall, detectBmadProjectState } from "./detector.js";
5
+ import { decideGate } from "./gates.js";
6
+ import { buildStoryPacket, selectPacketMode } from "./packets.js";
7
+ import { decideNextAction } from "./router.js";
8
+
9
+ export function registerBmadTool(pi: ExtensionAPI, cwd: string): void {
10
+ pi.registerTool({
11
+ name: "bmad_orchestrator",
12
+ label: "BMAD Orchestrator",
13
+ description: "Inspect BMAD project state and compute next action, story packet, or gate plan from project artifacts.",
14
+ promptSnippet: "Inspect BMAD artifact state and compute the next action, story packet, or gate path without making the model rescan the filesystem.",
15
+ promptGuidelines: [
16
+ "Use this tool when the user asks what BMAD phase the project is in, what to do next, what packet a story needs, or which review and gate workflows should run.",
17
+ ],
18
+ parameters: Type.Object({
19
+ action: StringEnum(["state", "next", "packet", "gate"] as const),
20
+ storyKey: Type.Optional(Type.String({ description: "Optional story key for packet generation." })),
21
+ }),
22
+ async execute(_toolCallId, params) {
23
+ const install = detectBmadInstall(cwd);
24
+ if (!install.installed) {
25
+ return {
26
+ content: [{ type: "text", text: "BMAD is not installed in this project." }],
27
+ details: { installed: false },
28
+ };
29
+ }
30
+
31
+ const state = detectBmadProjectState(cwd);
32
+
33
+ if (params.action === "state") {
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: `Phase ${state.phase}. Active story: ${state.activeStory ?? "none"}. Ready story: ${state.nextReadyStory ?? "none"}.`,
39
+ },
40
+ ],
41
+ details: { installed: true, state },
42
+ };
43
+ }
44
+
45
+ if (params.action === "next") {
46
+ const next = decideNextAction(state);
47
+ return {
48
+ content: [{ type: "text", text: `${next.command}: ${next.summary}` }],
49
+ details: { installed: true, next, state },
50
+ };
51
+ }
52
+
53
+ if (params.action === "packet") {
54
+ const storyKey = params.storyKey ?? state.activeStory ?? state.nextReadyStory;
55
+ if (!storyKey) {
56
+ return {
57
+ content: [{ type: "text", text: "No active or ready story was found for packet generation." }],
58
+ details: { installed: true, state },
59
+ };
60
+ }
61
+
62
+ const mode = selectPacketMode(state);
63
+ const packet = buildStoryPacket(state, storyKey, mode);
64
+ return {
65
+ content: [{ type: "text", text: `Packet ${packet.mode} for ${storyKey} with ${packet.files.length} files.` }],
66
+ details: { installed: true, packet, state },
67
+ };
68
+ }
69
+
70
+ const storyKey = params.storyKey ?? state.activeStory ?? state.nextReadyStory;
71
+ const gate = decideGate(state, install.modules.includes("tea"));
72
+ return {
73
+ content: [{ type: "text", text: `Gate ${gate.level} for ${storyKey ?? "current scope"}.` }],
74
+ details: { installed: true, storyKey, gate, state },
75
+ };
76
+ },
77
+ });
78
+ }