Compare commits
3 Commits
9f7a4e4535
...
c081358a95
| Author | SHA1 | Date |
|---|---|---|
|
|
c081358a95 | |
|
|
474aaf5428 | |
|
|
2936bf8a4e |
|
|
@ -0,0 +1,23 @@
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
extends: [
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['out/', 'node_modules/'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -85,11 +85,13 @@
|
||||||
"vscode:prepublish": "npm run compile",
|
"vscode:prepublish": "npm run compile",
|
||||||
"compile": "tsc -p ./",
|
"compile": "tsc -p ./",
|
||||||
"watch": "tsc -watch -p ./",
|
"watch": "tsc -watch -p ./",
|
||||||
"lint": "eslint src --ext ts"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/vscode": "^1.93.0",
|
"@types/vscode": "^1.93.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"typescript": "^5.5.0"
|
"eslint": "^9.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { logInfo, logWarn, logError } from './logger';
|
import { logInfo, logWarn } from './logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Well-known paths where BMAD is installed after `npx bmad-method install`.
|
* Well-known paths where BMAD is installed after `npx bmad-method install`.
|
||||||
|
|
@ -33,6 +33,10 @@ export interface BmadIndex {
|
||||||
|
|
||||||
// ─── Detection ──────────────────────────────────────────
|
// ─── Detection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the filesystem path of the first open workspace folder.
|
||||||
|
* @returns Absolute path, or `undefined` if no folder is open.
|
||||||
|
*/
|
||||||
function workspaceRoot(): string | undefined {
|
function workspaceRoot(): string | undefined {
|
||||||
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
}
|
}
|
||||||
|
|
@ -53,11 +57,17 @@ export function detectBmadRoot(): string | undefined {
|
||||||
|
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
const abs = path.isAbsolute(explicit) ? explicit : path.join(wsRoot, explicit);
|
const abs = path.isAbsolute(explicit) ? explicit : path.join(wsRoot, explicit);
|
||||||
if (fs.existsSync(abs)) {
|
try {
|
||||||
|
const stat = fs.statSync(abs);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
logWarn(`Configured bmad.rootPath "${explicit}" exists but is not a directory: ${abs}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
logInfo(`Using explicit bmad.rootPath: ${abs}`);
|
logInfo(`Using explicit bmad.rootPath: ${abs}`);
|
||||||
return abs;
|
return abs;
|
||||||
|
} catch {
|
||||||
|
logWarn(`Configured bmad.rootPath "${explicit}" not found at ${abs}`);
|
||||||
}
|
}
|
||||||
logWarn(`Configured bmad.rootPath "${explicit}" not found at ${abs}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoDetect = config.get<boolean>('autoDetect', true);
|
const autoDetect = config.get<boolean>('autoDetect', true);
|
||||||
|
|
@ -90,6 +100,16 @@ export function detectBmadRoot(): string | undefined {
|
||||||
/**
|
/**
|
||||||
* Lightweight extraction of metadata from YAML agent files.
|
* Lightweight extraction of metadata from YAML agent files.
|
||||||
* Reads plain text and uses regex — no YAML parser dependency.
|
* Reads plain text and uses regex — no YAML parser dependency.
|
||||||
|
*
|
||||||
|
* LIMITATION: The regexes below match any indented `key: value` line in the
|
||||||
|
* file, not only keys under specific YAML blocks (e.g. `metadata:` or
|
||||||
|
* `persona:`). This means a `title:` nested under an unrelated section
|
||||||
|
* could be picked up. This is a deliberate trade-off:
|
||||||
|
* - Pro: zero external dependencies, fast, simple.
|
||||||
|
* - Con: may over-match in unusual YAML structures.
|
||||||
|
* A full YAML parser (e.g. `yaml` or `js-yaml`) would eliminate the
|
||||||
|
* ambiguity but add a dependency and complexity not justified for
|
||||||
|
* display-only metadata hints.
|
||||||
*/
|
*/
|
||||||
function extractAgentMeta(content: string): { title?: string; description?: string; icon?: string; module?: string; role?: string } {
|
function extractAgentMeta(content: string): { title?: string; description?: string; icon?: string; module?: string; role?: string } {
|
||||||
const meta: { title?: string; description?: string; icon?: string; module?: string; role?: string } = {};
|
const meta: { title?: string; description?: string; icon?: string; module?: string; role?: string } = {};
|
||||||
|
|
@ -130,6 +150,12 @@ function extractWorkflowMeta(content: string): { name?: string; description?: st
|
||||||
|
|
||||||
// ─── Recursive file walker ──────────────────────────────
|
// ─── Recursive file walker ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect all file paths under {@link dir}.
|
||||||
|
* Silently skips directories that cannot be read.
|
||||||
|
* @param dir - Root directory to walk.
|
||||||
|
* @returns Array of absolute file paths.
|
||||||
|
*/
|
||||||
function walkAll(dir: string): string[] {
|
function walkAll(dir: string): string[] {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
function walk(current: string): void {
|
function walk(current: string): void {
|
||||||
|
|
@ -154,20 +180,43 @@ function walkAll(dir: string): string[] {
|
||||||
|
|
||||||
// ─── Name derivation ────────────────────────────────────
|
// ─── Name derivation ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a filename matches the BMAD agent naming convention (`*.agent.yaml` / `*.agent.md`).
|
||||||
|
* @param filename - Base filename (no directory component).
|
||||||
|
* @returns `true` if the file is an agent definition.
|
||||||
|
*/
|
||||||
function isAgentFile(filename: string): boolean {
|
function isAgentFile(filename: string): boolean {
|
||||||
return /\.agent\.(yaml|md)$/i.test(filename);
|
return /\.agent\.(yaml|md)$/i.test(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a filename matches the BMAD workflow naming convention
|
||||||
|
* (`workflow.yaml`, `workflow.md`, `workflow-*.yaml`, `workflow-*.md`).
|
||||||
|
* @param filename - Base filename (no directory component).
|
||||||
|
* @returns `true` if the file is a workflow definition.
|
||||||
|
*/
|
||||||
function isWorkflowFile(filename: string): boolean {
|
function isWorkflowFile(filename: string): boolean {
|
||||||
// Matches: workflow.yaml, workflow.md, workflow-*.md, workflow-*.yaml
|
// Matches: workflow.yaml, workflow.md, workflow-*.md, workflow-*.yaml
|
||||||
return /^workflow(-[\w-]+)?\.(yaml|md)$/i.test(filename);
|
return /^workflow(-[\w-]+)?\.(yaml|md)$/i.test(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a short display name from an agent file path by stripping the `.agent.yaml/.md` suffix.
|
||||||
|
* @param filePath - Absolute path to the agent file.
|
||||||
|
* @returns Display name (e.g. `"analyst"`).
|
||||||
|
*/
|
||||||
function deriveAgentName(filePath: string): string {
|
function deriveAgentName(filePath: string): string {
|
||||||
const base = path.basename(filePath);
|
const base = path.basename(filePath);
|
||||||
return base.replace(/\.agent\.(yaml|md)$/i, '');
|
return base.replace(/\.agent\.(yaml|md)$/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a display name for a workflow. Prefers the `name:` key from YAML
|
||||||
|
* metadata; falls back to stripping the `workflow-` prefix and extension.
|
||||||
|
* @param filePath - Absolute path to the workflow file.
|
||||||
|
* @param content - Raw file content (used for metadata extraction).
|
||||||
|
* @returns Display name (e.g. `"create-prd"`).
|
||||||
|
*/
|
||||||
function deriveWorkflowName(filePath: string, content: string): string {
|
function deriveWorkflowName(filePath: string, content: string): string {
|
||||||
// Prefer name from YAML metadata
|
// Prefer name from YAML metadata
|
||||||
const meta = extractWorkflowMeta(content);
|
const meta = extractWorkflowMeta(content);
|
||||||
|
|
@ -193,6 +242,12 @@ function inferModule(filePath: string, bmadRoot: string): string | undefined {
|
||||||
|
|
||||||
// ─── Build index ────────────────────────────────────────
|
// ─── Build index ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the BMAD root directory and build a complete index of agents and workflows.
|
||||||
|
* Reads each matching file to extract metadata. Deduplicates workflows by name.
|
||||||
|
* @param bmadRoot - Absolute path to the detected BMAD root directory.
|
||||||
|
* @returns A {@link BmadIndex} with sorted agent and workflow lists.
|
||||||
|
*/
|
||||||
export function buildIndex(bmadRoot: string): BmadIndex {
|
export function buildIndex(bmadRoot: string): BmadIndex {
|
||||||
const wsRoot = workspaceRoot() ?? bmadRoot;
|
const wsRoot = workspaceRoot() ?? bmadRoot;
|
||||||
const allFiles = walkAll(bmadRoot);
|
const allFiles = walkAll(bmadRoot);
|
||||||
|
|
@ -251,10 +306,18 @@ export function buildIndex(bmadRoot: string): BmadIndex {
|
||||||
let _index: BmadIndex | undefined;
|
let _index: BmadIndex | undefined;
|
||||||
let _watcher: vscode.FileSystemWatcher | undefined;
|
let _watcher: vscode.FileSystemWatcher | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current in-memory BMAD index without rebuilding.
|
||||||
|
* @returns The cached index, or `undefined` if not yet built.
|
||||||
|
*/
|
||||||
export function getIndex(): BmadIndex | undefined {
|
export function getIndex(): BmadIndex | undefined {
|
||||||
return _index;
|
return _index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-detect the BMAD root and rebuild the index from disk.
|
||||||
|
* @returns The newly built index, or `undefined` if no BMAD root was found.
|
||||||
|
*/
|
||||||
export function refreshIndex(): BmadIndex | undefined {
|
export function refreshIndex(): BmadIndex | undefined {
|
||||||
const root = detectBmadRoot();
|
const root = detectBmadRoot();
|
||||||
if (!root) {
|
if (!root) {
|
||||||
|
|
@ -265,6 +328,11 @@ export function refreshIndex(): BmadIndex | undefined {
|
||||||
return _index;
|
return _index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a file-system watcher that rebuilds the BMAD index when
|
||||||
|
* YAML/Markdown files in the workspace change. Changes are debounced (500 ms).
|
||||||
|
* @param ctx - Extension context for disposable management.
|
||||||
|
*/
|
||||||
export function startWatching(ctx: vscode.ExtensionContext): void {
|
export function startWatching(ctx: vscode.ExtensionContext): void {
|
||||||
const wsRoot = workspaceRoot();
|
const wsRoot = workspaceRoot();
|
||||||
if (!wsRoot) { return; }
|
if (!wsRoot) { return; }
|
||||||
|
|
@ -273,13 +341,20 @@ export function startWatching(ctx: vscode.ExtensionContext): void {
|
||||||
const pattern = new vscode.RelativePattern(wsRoot, '**/*.{yaml,md}');
|
const pattern = new vscode.RelativePattern(wsRoot, '**/*.{yaml,md}');
|
||||||
_watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
_watcher = vscode.workspace.createFileSystemWatcher(pattern);
|
||||||
|
|
||||||
|
// Debounce: the glob matches all yaml/md files so unrelated edits may
|
||||||
|
// fire frequently. Collapse rapid bursts into a single rebuild.
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const rebuild = () => {
|
const rebuild = () => {
|
||||||
logInfo('File change detected — rebuilding index');
|
if (debounceTimer) { clearTimeout(debounceTimer); }
|
||||||
refreshIndex();
|
debounceTimer = setTimeout(() => {
|
||||||
|
logInfo('File change detected — rebuilding index');
|
||||||
|
refreshIndex();
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
_watcher.onDidCreate(rebuild);
|
_watcher.onDidCreate(rebuild);
|
||||||
_watcher.onDidDelete(rebuild);
|
_watcher.onDidDelete(rebuild);
|
||||||
_watcher.onDidChange(rebuild);
|
_watcher.onDidChange(rebuild);
|
||||||
ctx.subscriptions.push(_watcher);
|
ctx.subscriptions.push(_watcher);
|
||||||
|
ctx.subscriptions.push({ dispose: () => { if (debounceTimer) { clearTimeout(debounceTimer); } } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { logInfo, logWarn, logError } from './logger';
|
||||||
|
|
||||||
// ─── Dynamic help builder ───────────────────────────────
|
// ─── Dynamic help builder ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build dynamic help Markdown that lists all slash commands and currently installed items.
|
||||||
|
* @param idx - Current BMAD index (may be `undefined` if not yet detected).
|
||||||
|
* @returns Formatted Markdown string.
|
||||||
|
*/
|
||||||
function buildHelpText(idx: BmadIndex | undefined): string {
|
function buildHelpText(idx: BmadIndex | undefined): string {
|
||||||
let md = `## BMAD Copilot — Available Commands\n\n`;
|
let md = `## BMAD Copilot — Available Commands\n\n`;
|
||||||
md += `| Command | Description |\n`;
|
md += `| Command | Description |\n`;
|
||||||
|
|
@ -42,6 +47,11 @@ function buildHelpText(idx: BmadIndex | undefined): string {
|
||||||
|
|
||||||
const KNOWN_SUBS = ['help', 'doctor', 'list', 'run', 'agents', 'workflows'];
|
const KNOWN_SUBS = ['help', 'doctor', 'list', 'run', 'agents', 'workflows'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest the closest known slash command for a mistyped input using positional character overlap.
|
||||||
|
* @param input - The unrecognised command string the user typed.
|
||||||
|
* @returns The best-matching known command name (falls back to `"help"`).
|
||||||
|
*/
|
||||||
function suggestCommand(input: string): string {
|
function suggestCommand(input: string): string {
|
||||||
let best = '';
|
let best = '';
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
|
|
@ -57,11 +67,19 @@ function suggestCommand(input: string): string {
|
||||||
|
|
||||||
// ─── Sub-command handlers ───────────────────────────────
|
// ─── Sub-command handlers ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the `/help` command — render dynamic help text.
|
||||||
|
* @param stream - Chat response stream to write Markdown into.
|
||||||
|
*/
|
||||||
function handleHelp(stream: vscode.ChatResponseStream): void {
|
function handleHelp(stream: vscode.ChatResponseStream): void {
|
||||||
const idx = getIndex() ?? refreshIndex();
|
const idx = getIndex() ?? refreshIndex();
|
||||||
stream.markdown(buildHelpText(idx));
|
stream.markdown(buildHelpText(idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the `/doctor` command — render BMAD installation diagnostics.
|
||||||
|
* @param stream - Chat response stream to write Markdown into.
|
||||||
|
*/
|
||||||
function handleDoctor(stream: vscode.ChatResponseStream): void {
|
function handleDoctor(stream: vscode.ChatResponseStream): void {
|
||||||
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '(no workspace)';
|
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '(no workspace)';
|
||||||
const config = vscode.workspace.getConfiguration('bmad');
|
const config = vscode.workspace.getConfiguration('bmad');
|
||||||
|
|
@ -102,6 +120,11 @@ function handleDoctor(stream: vscode.ChatResponseStream): void {
|
||||||
stream.markdown(md);
|
stream.markdown(md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the `/list` command — display agents or workflows in a table.
|
||||||
|
* @param stream - Chat response stream.
|
||||||
|
* @param args - Remaining tokens (expects `["agents"]` or `["workflows"]`).
|
||||||
|
*/
|
||||||
function handleList(stream: vscode.ChatResponseStream, args: string[]): void {
|
function handleList(stream: vscode.ChatResponseStream, args: string[]): void {
|
||||||
const idx = getIndex() ?? refreshIndex();
|
const idx = getIndex() ?? refreshIndex();
|
||||||
if (!idx) {
|
if (!idx) {
|
||||||
|
|
@ -119,6 +142,13 @@ function handleList(stream: vscode.ChatResponseStream, args: string[]): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Markdown table of BMAD items (agents or workflows).
|
||||||
|
* @param stream - Chat response stream.
|
||||||
|
* @param title - Section heading (e.g. `"Agents"`).
|
||||||
|
* @param items - Items to display.
|
||||||
|
* @param isAgent - `true` to show agent-specific columns (Title, Icon); `false` for workflow columns (Description).
|
||||||
|
*/
|
||||||
function listItems(stream: vscode.ChatResponseStream, title: string, items: BmadItem[], isAgent: boolean): void {
|
function listItems(stream: vscode.ChatResponseStream, title: string, items: BmadItem[], isAgent: boolean): void {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
stream.markdown(`## ${title}\n\n_No items found._\n`);
|
stream.markdown(`## ${title}\n\n_No items found._\n`);
|
||||||
|
|
@ -141,12 +171,48 @@ function listItems(stream: vscode.ChatResponseStream, title: string, items: Bmad
|
||||||
stream.markdown(md);
|
stream.markdown(md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string to at most {@link max} characters, appending `'…'` if shortened.
|
||||||
|
* @param s - Input string.
|
||||||
|
* @param max - Maximum allowed length.
|
||||||
|
* @returns The (possibly truncated) string.
|
||||||
|
*/
|
||||||
function truncate(s: string, max: number): string {
|
function truncate(s: string, max: number): string {
|
||||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap `content` in a Markdown fenced code block using a fence that is
|
||||||
|
* guaranteed not to collide with any backtick sequence inside the content.
|
||||||
|
*
|
||||||
|
* Algorithm: find the longest run of consecutive backticks in `content`,
|
||||||
|
* then use a fence that is at least one backtick longer (minimum 3).
|
||||||
|
*/
|
||||||
|
function safeFence(content: string): string {
|
||||||
|
let maxRun = 0;
|
||||||
|
let run = 0;
|
||||||
|
for (const ch of content) {
|
||||||
|
if (ch === '`') {
|
||||||
|
run++;
|
||||||
|
if (run > maxRun) { maxRun = run; }
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fence = '`'.repeat(Math.max(3, maxRun + 1));
|
||||||
|
return `${fence}\n${content}\n${fence}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Run agent / workflow ───────────────────────────────
|
// ─── Run agent / workflow ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a resolved run target by injecting its definition into the LLM context.
|
||||||
|
* Falls back to displaying a copyable prompt if the Language Model API is unavailable.
|
||||||
|
* @param request - Original chat request (provides the language model).
|
||||||
|
* @param stream - Chat response stream for output.
|
||||||
|
* @param token - Cancellation token.
|
||||||
|
* @param resolved - The agent/workflow and task to execute.
|
||||||
|
*/
|
||||||
async function executeRun(
|
async function executeRun(
|
||||||
request: vscode.ChatRequest,
|
request: vscode.ChatRequest,
|
||||||
stream: vscode.ChatResponseStream,
|
stream: vscode.ChatResponseStream,
|
||||||
|
|
@ -199,6 +265,15 @@ async function executeRun(
|
||||||
fallbackPrompt(stream, item, kind, fileContent, task);
|
fallbackPrompt(stream, item, kind, fileContent, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the `/run` command — resolve args to an agent/workflow and execute it.
|
||||||
|
* Shows helpful feedback with closest-name suggestions on resolution failure.
|
||||||
|
* @param request - Original chat request.
|
||||||
|
* @param stream - Chat response stream.
|
||||||
|
* @param token - Cancellation token.
|
||||||
|
* @param args - Parsed tokens after `/run`.
|
||||||
|
* @returns A promise that resolves when execution is complete.
|
||||||
|
*/
|
||||||
function handleRun(
|
function handleRun(
|
||||||
request: vscode.ChatRequest,
|
request: vscode.ChatRequest,
|
||||||
stream: vscode.ChatResponseStream,
|
stream: vscode.ChatResponseStream,
|
||||||
|
|
@ -231,6 +306,15 @@ function handleRun(
|
||||||
return executeRun(request, stream, token, resolved);
|
return executeRun(request, stream, token, resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the assembled prompt as a copyable code block when the LLM API is unavailable.
|
||||||
|
* Includes a "Copy Prompt" button bound to the clipboard command.
|
||||||
|
* @param stream - Chat response stream.
|
||||||
|
* @param item - The agent or workflow item.
|
||||||
|
* @param kind - `"agent"` or `"workflow"`.
|
||||||
|
* @param fileContent - Raw content of the definition file.
|
||||||
|
* @param task - User-supplied task description.
|
||||||
|
*/
|
||||||
function fallbackPrompt(
|
function fallbackPrompt(
|
||||||
stream: vscode.ChatResponseStream,
|
stream: vscode.ChatResponseStream,
|
||||||
item: BmadItem,
|
item: BmadItem,
|
||||||
|
|
@ -242,7 +326,7 @@ function fallbackPrompt(
|
||||||
|
|
||||||
const assembled = `I want you to adopt the following ${kind} persona and follow its instructions exactly.\n\n--- BEGIN ${kind.toUpperCase()} DEFINITION ---\n${fileContent}\n--- END ${kind.toUpperCase()} DEFINITION ---\n\nNow respond to this task:\n${task}`;
|
const assembled = `I want you to adopt the following ${kind} persona and follow its instructions exactly.\n\n--- BEGIN ${kind.toUpperCase()} DEFINITION ---\n${fileContent}\n--- END ${kind.toUpperCase()} DEFINITION ---\n\nNow respond to this task:\n${task}`;
|
||||||
|
|
||||||
stream.markdown('```\n' + assembled + '\n```\n');
|
stream.markdown(safeFence(assembled) + '\n');
|
||||||
|
|
||||||
stream.button({
|
stream.button({
|
||||||
title: 'Copy Prompt',
|
title: 'Copy Prompt',
|
||||||
|
|
@ -253,6 +337,10 @@ function fallbackPrompt(
|
||||||
|
|
||||||
// ─── Main handler ───────────────────────────────────────
|
// ─── Main handler ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main `@bmad` chat participant request handler.
|
||||||
|
* Routes incoming commands to the appropriate sub-handler and returns an empty result.
|
||||||
|
*/
|
||||||
export const chatHandler: vscode.ChatRequestHandler = async (
|
export const chatHandler: vscode.ChatRequestHandler = async (
|
||||||
request: vscode.ChatRequest,
|
request: vscode.ChatRequest,
|
||||||
context: vscode.ChatContext,
|
context: vscode.ChatContext,
|
||||||
|
|
|
||||||
|
|
@ -128,10 +128,24 @@ export function resolveDirectKind(kind: ResolvedKind, args: string[], index: Bma
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a token looks like a BMAD module name (e.g. `"bmm"`, `"core"`).
|
||||||
|
* Rejects known command keywords so they are not confused with modules.
|
||||||
|
* @param s - Lowercased token to check.
|
||||||
|
* @returns `true` if the token is a plausible module name.
|
||||||
|
*/
|
||||||
function isModuleName(s: string): boolean {
|
function isModuleName(s: string): boolean {
|
||||||
return /^[a-z][a-z0-9_-]*$/.test(s) && !['agent', 'workflow', 'a', 'w', 'agents', 'workflows'].includes(s);
|
return /^[a-z][a-z0-9_-]*$/.test(s) && !['agent', 'workflow', 'a', 'w', 'agents', 'workflows'].includes(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a named item of the given kind in the index.
|
||||||
|
* @param kind - `'agent'` or `'workflow'`.
|
||||||
|
* @param args - Remaining tokens: `[name, ...taskWords]`.
|
||||||
|
* @param index - Current BMAD index.
|
||||||
|
* @param moduleFilter - Optional module name to narrow the search.
|
||||||
|
* @returns Resolved target, or `undefined` if no match is found.
|
||||||
|
*/
|
||||||
function findItem(kind: ResolvedKind, args: string[], index: BmadIndex, moduleFilter?: string): ResolvedRunTarget | undefined {
|
function findItem(kind: ResolvedKind, args: string[], index: BmadIndex, moduleFilter?: string): ResolvedRunTarget | undefined {
|
||||||
if (args.length === 0) { return undefined; }
|
if (args.length === 0) { return undefined; }
|
||||||
const name = args[0].toLowerCase();
|
const name = args[0].toLowerCase();
|
||||||
|
|
@ -146,6 +160,12 @@ function findItem(kind: ResolvedKind, args: string[], index: BmadIndex, moduleFi
|
||||||
return { kind, item, task };
|
return { kind, item, task };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-resolve a name to an agent or workflow (agent takes priority).
|
||||||
|
* @param name - Item name to look up (case-insensitive).
|
||||||
|
* @param index - Current BMAD index.
|
||||||
|
* @returns The matched kind and item, or `undefined`.
|
||||||
|
*/
|
||||||
function findByName(name: string, index: BmadIndex): { kind: ResolvedKind; item: BmadItem } | undefined {
|
function findByName(name: string, index: BmadIndex): { kind: ResolvedKind; item: BmadItem } | undefined {
|
||||||
const n = name.toLowerCase();
|
const n = name.toLowerCase();
|
||||||
const agent = index.agents.find(a => a.name.toLowerCase() === n);
|
const agent = index.agents.find(a => a.name.toLowerCase() === n);
|
||||||
|
|
@ -171,16 +191,40 @@ export function findClosestName(input: string, index: BmadIndex): string | undef
|
||||||
// Substring match
|
// Substring match
|
||||||
const sub = all.find(n => n.toLowerCase().includes(lower));
|
const sub = all.find(n => n.toLowerCase().includes(lower));
|
||||||
if (sub) { return sub; }
|
if (sub) { return sub; }
|
||||||
// Levenshtein-like: best character overlap
|
// Fall back to true Levenshtein distance — O(n*m) per candidate but
|
||||||
|
// the candidate list is small (tens of items) so this is fine for a
|
||||||
|
// hint-only code path.
|
||||||
let best = all[0];
|
let best = all[0];
|
||||||
let bestScore = 0;
|
let bestDist = Infinity;
|
||||||
for (const n of all) {
|
for (const n of all) {
|
||||||
let score = 0;
|
const d = levenshtein(lower, n.toLowerCase());
|
||||||
const nl = n.toLowerCase();
|
if (d < bestDist) { bestDist = d; best = n; }
|
||||||
for (let i = 0; i < Math.min(lower.length, nl.length); i++) {
|
|
||||||
if (lower[i] === nl[i]) { score++; }
|
|
||||||
}
|
|
||||||
if (score > bestScore) { bestScore = score; best = n; }
|
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Levenshtein distance (edit distance) between two strings.
|
||||||
|
* Handles insertions, deletions and substitutions.
|
||||||
|
* Uses a single-row DP approach to keep memory at O(min(a,b)).
|
||||||
|
*/
|
||||||
|
function levenshtein(a: string, b: string): number {
|
||||||
|
if (a === b) { return 0; }
|
||||||
|
if (a.length === 0) { return b.length; }
|
||||||
|
if (b.length === 0) { return a.length; }
|
||||||
|
// Ensure a is the shorter string for memory efficiency
|
||||||
|
if (a.length > b.length) { [a, b] = [b, a]; }
|
||||||
|
const row = Array.from({ length: a.length + 1 }, (_, i) => i);
|
||||||
|
for (let j = 1; j <= b.length; j++) {
|
||||||
|
let prev = row[0];
|
||||||
|
row[0] = j;
|
||||||
|
for (let i = 1; i <= a.length; i++) {
|
||||||
|
const cur = row[i];
|
||||||
|
row[i] = a[i - 1] === b[j - 1]
|
||||||
|
? prev
|
||||||
|
: 1 + Math.min(prev, row[i], row[i - 1]);
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row[a.length];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import { initLogger, logInfo } from './logger';
|
||||||
import { refreshIndex, startWatching } from './bmadIndex';
|
import { refreshIndex, startWatching } from './bmadIndex';
|
||||||
import { chatHandler } from './chatHandler';
|
import { chatHandler } from './chatHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension entry point — registers the `@bmad` chat participant,
|
||||||
|
* builds the initial BMAD index, and wires up file/config watchers.
|
||||||
|
* @param context - VS Code extension context for lifecycle management.
|
||||||
|
*/
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
// ── Logger ──
|
// ── Logger ──
|
||||||
initLogger(context);
|
initLogger(context);
|
||||||
|
|
@ -45,6 +50,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
logInfo('BMAD Copilot extension activated.');
|
logInfo('BMAD Copilot extension activated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extension teardown. Cleanup is handled automatically by `context.subscriptions`. */
|
||||||
export function deactivate(): void {
|
export function deactivate(): void {
|
||||||
// Cleanup handled by context.subscriptions
|
// Cleanup handled by context.subscriptions
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,42 @@ import * as vscode from 'vscode';
|
||||||
const CHANNEL_NAME = 'BMAD Copilot';
|
const CHANNEL_NAME = 'BMAD Copilot';
|
||||||
let _channel: vscode.OutputChannel | undefined;
|
let _channel: vscode.OutputChannel | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the shared output channel for extension logging.
|
||||||
|
* @param ctx - Extension context whose subscriptions manage the channel lifecycle.
|
||||||
|
* @returns The created {@link vscode.OutputChannel}.
|
||||||
|
*/
|
||||||
export function initLogger(ctx: vscode.ExtensionContext): vscode.OutputChannel {
|
export function initLogger(ctx: vscode.ExtensionContext): vscode.OutputChannel {
|
||||||
_channel = vscode.window.createOutputChannel(CHANNEL_NAME);
|
_channel = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||||
ctx.subscriptions.push(_channel);
|
ctx.subscriptions.push(_channel);
|
||||||
return _channel;
|
return _channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns Current time as `HH:MM:SS.mmm` for log line prefixes. */
|
||||||
function ts(): string {
|
function ts(): string {
|
||||||
return new Date().toISOString().slice(11, 23);
|
return new Date().toISOString().slice(11, 23);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an INFO-level message to the output channel.
|
||||||
|
* @param msg - The message to log.
|
||||||
|
*/
|
||||||
export function logInfo(msg: string): void {
|
export function logInfo(msg: string): void {
|
||||||
_channel?.appendLine(`[${ts()}] INFO ${msg}`);
|
_channel?.appendLine(`[${ts()}] INFO ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a WARN-level message to the output channel.
|
||||||
|
* @param msg - The message to log.
|
||||||
|
*/
|
||||||
export function logWarn(msg: string): void {
|
export function logWarn(msg: string): void {
|
||||||
_channel?.appendLine(`[${ts()}] WARN ${msg}`);
|
_channel?.appendLine(`[${ts()}] WARN ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an ERROR-level message to the output channel.
|
||||||
|
* @param msg - The message to log.
|
||||||
|
*/
|
||||||
export function logError(msg: string): void {
|
export function logError(msg: string): void {
|
||||||
_channel?.appendLine(`[${ts()}] ERROR ${msg}`);
|
_channel?.appendLine(`[${ts()}] ERROR ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue