diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d454bb0b..8e94b967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,11 +59,19 @@ jobs: # Run tests in batches to avoid mock.module() conflicts between test files # Each batch outputs its own lcov file for later merging - echo "=== Running tests/ (excluding commands/info.test.ts) ===" | tee -a coverage-output.txt - mapfile -t TEST_FILES < <(find tests -type f -name '*.test.ts' ! -path 'tests/commands/info.test.ts' | sort) + echo "=== Running tests/ (excluding commands/info.test.ts and beads-rust-bv-tracker.test.ts) ===" | tee -a coverage-output.txt + mapfile -t TEST_FILES < <(find tests -type f -name '*.test.ts' ! -path 'tests/commands/info.test.ts' ! -path 'tests/plugins/beads-rust-bv-tracker.test.ts' | sort) bun test "${TEST_FILES[@]}" --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt cp coverage/lcov.info coverage-parts/tests.lcov + # Run beads-rust-bv-tracker test in isolation: it mocks node:child_process and + # node:fs/promises via mock.module() which is process-wide in Bun and pollutes + # other tests (json-tracker, gemini-agent detect, remote Config Push Feature) + # when run in the same process. + echo "=== Running tests/plugins/beads-rust-bv-tracker.test.ts (isolated) ===" | tee -a coverage-output.txt + bun test tests/plugins/beads-rust-bv-tracker.test.ts --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt + cp coverage/lcov.info coverage-parts/tests-beads-rust-bv.lcov + echo "=== Running tests/commands/info.test.ts (isolated) ===" | tee -a coverage-output.txt bun test tests/commands/info.test.ts --coverage --coverage-reporter=text --coverage-reporter=lcov 2>&1 | tee -a coverage-output.txt cp coverage/lcov.info coverage-parts/tests-info.lcov @@ -199,7 +207,7 @@ jobs: uses: codecov/codecov-action@v4 with: # Upload all batch coverage files - Codecov will merge them correctly - files: ./coverage-parts/tests.lcov,./coverage-parts/tests-info.lcov,./coverage-parts/doctor.lcov,./coverage-parts/info.lcov,./coverage-parts/skills.lcov,./coverage-parts/run.lcov,./coverage-parts/config.lcov,./coverage-parts/engine.lcov,./coverage-parts/beads-bv.lcov,./coverage-parts/beads-rust.lcov,./coverage-parts/beads.lcov,./coverage-parts/plugins.lcov,./coverage-parts/session.lcov,./coverage-parts/sandbox.lcov,./coverage-parts/wizard.lcov,./coverage-parts/setup.lcov,./coverage-parts/skill-installer-spawn.lcov,./coverage-parts/migration-install.lcov,./coverage-parts/templates.lcov,./coverage-parts/tui.lcov,./coverage-parts/prd.lcov,./coverage-parts/chat.lcov,./coverage-parts/parallel.lcov + files: ./coverage-parts/tests.lcov,./coverage-parts/tests-beads-rust-bv.lcov,./coverage-parts/tests-info.lcov,./coverage-parts/doctor.lcov,./coverage-parts/info.lcov,./coverage-parts/skills.lcov,./coverage-parts/run.lcov,./coverage-parts/config.lcov,./coverage-parts/engine.lcov,./coverage-parts/beads-bv.lcov,./coverage-parts/beads-rust.lcov,./coverage-parts/beads.lcov,./coverage-parts/plugins.lcov,./coverage-parts/session.lcov,./coverage-parts/sandbox.lcov,./coverage-parts/wizard.lcov,./coverage-parts/setup.lcov,./coverage-parts/skill-installer-spawn.lcov,./coverage-parts/migration-install.lcov,./coverage-parts/templates.lcov,./coverage-parts/tui.lcov,./coverage-parts/prd.lcov,./coverage-parts/chat.lcov,./coverage-parts/parallel.lcov fail_ci_if_error: false verbose: true env: diff --git a/src/engine/index.ts b/src/engine/index.ts index 1efd971a..74c65448 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -160,11 +160,19 @@ async function buildPrompt( // Get PRD context if the tracker supports it const prdContext = await tracker?.getPrdContext?.(); + // Build selection reason from bv metadata (if present) + const bvReasons = task.metadata?.bvReasons; + const selectionReason = + Array.isArray(bvReasons) && bvReasons.length > 0 + ? bvReasons.join('\n') + : undefined; + // Build extended template context with PRD data and patterns const extendedContext = { recentProgress, codebasePatterns, prd: prdContext ?? undefined, + selectionReason, }; // Use the template system (tracker template used if no custom/user override) diff --git a/src/plugins/trackers/builtin/beads-rust-bv/index.ts b/src/plugins/trackers/builtin/beads-rust-bv/index.ts new file mode 100644 index 00000000..2f9cdfbe --- /dev/null +++ b/src/plugins/trackers/builtin/beads-rust-bv/index.ts @@ -0,0 +1,631 @@ +/** + * ABOUTME: Beads-Rust + Beads Viewer (bv) tracker plugin for smart task selection. + * Combines the br CLI (beads-rust) for task CRUD with bv graph-aware algorithms + * (--robot-next) for optimal task ordering. Delegates all br operations to an + * internal BeadsRustTrackerPlugin instance. Falls back gracefully when bv is + * unavailable. + */ + +import { spawn } from 'node:child_process'; +import { BaseTrackerPlugin } from '../../base.js'; +import { + BeadsRustTrackerPlugin, + type BeadsRustDetectResult, +} from '../beads-rust/index.js'; +import { BEADS_RUST_BV_TEMPLATE } from '../../../../templates/builtin.js'; +import type { + TrackerPluginMeta, + TrackerPluginFactory, + TrackerTask, + TrackerTaskStatus, + TaskFilter, + TaskCompletionResult, + SyncResult, + SetupQuestion, +} from '../../types.js'; + +const TRIAGE_REFRESH_MIN_INTERVAL_MS = 30_000; + +/** + * Output from bv --robot-next when an actionable task exists. + */ +interface BvRobotNextTask { + generated_at: string; + data_hash: string; + output_format: string; + id: string; + title: string; + score: number; + reasons: string[]; + unblocks: number; + claim_command: string; + show_command: string; +} + +/** + * Output from bv --robot-next when no actionable items are available. + */ +interface BvRobotNextEmpty { + generated_at: string; + data_hash: string; + output_format: string; + message: string; +} + +type BvRobotNextOutput = BvRobotNextTask | BvRobotNextEmpty; + +/** + * Structure of bv --robot-triage JSON output (subset we use for metadata). + */ +interface BvTriageRecommendation { + id: string; + score: number; + reasons: string[]; + unblocks?: number; +} + +interface BvTriageOutput { + triage: { + recommendations: BvTriageRecommendation[]; + }; +} + +/** + * Detection result including bv availability. + */ +export interface BeadsRustBvDetectResult extends BeadsRustDetectResult { + bvAvailable: boolean; + bvPath?: string; +} + +/** + * Execute a bv command and return stdout/stderr/exitCode. + */ +const EXEC_BV_TIMEOUT_MS = 15_000; + +async function execBv( + args: string[], + cwd?: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + let resolved = false; + const proc = spawn('bv', args, { + cwd, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + stderr += `\nbv timed out after ${EXEC_BV_TIMEOUT_MS}ms`; + proc.kill('SIGKILL'); + resolve({ stdout, stderr, exitCode: 1 }); + } + }, EXEC_BV_TIMEOUT_MS); + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + resolve({ stdout, stderr, exitCode: code ?? 1 }); + } + }); + + proc.on('error', (err) => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + stderr += err.message; + resolve({ stdout, stderr, exitCode: 1 }); + } + }); + }); +} + +function hasMessageField(value: unknown): value is { message: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).message === 'string' + ); +} + +function hasValidTaskId(value: unknown): value is { id: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Record).id === 'string' && + ((value as Record).id as string).trim().length > 0 + ); +} + +/** + * Beads-Rust + bv tracker plugin. + * + * Extends BaseTrackerPlugin and composes a BeadsRustTrackerPlugin internally + * to handle all br-CLI operations. Overrides getNextTask to use bv's graph + * analysis and decorates getTasks with bv score metadata. + */ +export class BeadsRustBvTrackerPlugin extends BaseTrackerPlugin { + override readonly meta: TrackerPluginMeta = { + id: 'beads-rust-bv', + name: 'Beads Rust + Beads Viewer (Smart Mode)', + description: + 'Smart task selection using bv graph analysis (PageRank, critical path) with the br CLI', + version: '1.0.0', + supportsBidirectionalSync: true, + supportsHierarchy: true, + supportsDependencies: true, + }; + + /** Internal beads-rust delegate for all br operations. */ + private readonly delegate: BeadsRustTrackerPlugin; + + private bvAvailable = false; + private lastTriageOutput: BvTriageOutput | null = null; + private triageRefreshInFlight: Promise | null = null; + private pendingForcedTriageRefresh = false; + private lastTriageRefreshAt = 0; + private workingDir: string = process.cwd(); + private labels: string[] = []; + + constructor() { + super(); + this.delegate = new BeadsRustTrackerPlugin(); + } + + override async initialize(config: Record): Promise { + await super.initialize(config); + + // Store working dir and labels for bv invocations. + if (typeof config.workingDir === 'string') { + this.workingDir = config.workingDir; + } + if (typeof config.labels === 'string') { + this.labels = config.labels + .split(',') + .map((l) => l.trim()) + .filter(Boolean); + } else if (Array.isArray(config.labels)) { + this.labels = config.labels.filter( + (l): l is string => typeof l === 'string' + ); + } + + // Initialize the delegate (br detection, epic, labels). + await this.delegate.initialize(config); + + // Determine overall readiness and bv availability. + const detection = await this.detect(); + this.ready = detection.available; + this.bvAvailable = detection.bvAvailable; + } + + /** + * Detect br and bv availability. + */ + async detect(): Promise { + const brDetect = await this.delegate.detect(); + + if (!brDetect.available) { + return { ...brDetect, bvAvailable: false }; + } + + const bvResult = await execBv(['--version'], this.workingDir); + const bvAvailable = bvResult.exitCode === 0; + + return { + ...brDetect, + bvAvailable, + bvPath: bvAvailable ? 'bv' : undefined, + }; + } + + override async isReady(): Promise { + return this.ready; + } + + // ------------------------------------------------------------------------- + // Delegation to BeadsRustTrackerPlugin for all br operations + // ------------------------------------------------------------------------- + + override async getTasks(filter?: TaskFilter): Promise { + const tasks = await this.delegate.getTasks(filter); + + // Decorate with bv score metadata when triage data is available. + if ( + this.bvAvailable && + this.lastTriageOutput && + this.lastTriageOutput.triage && + Array.isArray(this.lastTriageOutput.triage.recommendations) + ) { + const recMap = new Map(); + for (const rec of this.lastTriageOutput.triage.recommendations) { + if ( + typeof rec === 'object' && + rec !== null && + 'id' in rec && + typeof (rec as BvTriageRecommendation).id === 'string' + ) { + recMap.set((rec as BvTriageRecommendation).id, rec as BvTriageRecommendation); + } + } + for (const task of tasks) { + const rec = recMap.get(task.id); + if (rec) { + task.metadata = { + ...task.metadata, + bvScore: rec.score, + bvReasons: rec.reasons, + bvUnblocks: rec.unblocks ?? 0, + }; + } + } + } + + return tasks; + } + + override async getTask(id: string): Promise { + return this.delegate.getTask(id); + } + + /** + * Get the next task using bv's --robot-next when available. + * Falls back to the beads-rust delegate (br ready) on any failure. + */ + override async getNextTask( + filter?: TaskFilter + ): Promise { + if (!this.bvAvailable) { + return this.delegate.getNextTask(filter); + } + + const statusFilter = this.normalizeStatusFilter(filter?.status); + + try { + // --robot-next only returns actionable tasks (open/in_progress). + // If a caller asks for only non-actionable statuses, there is no + // valid --robot-next result by definition. + if ( + statusFilter && + !statusFilter.some( + (status) => status === 'open' || status === 'in_progress' + ) + ) { + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + const args = ['--robot-next']; + + // Forward label filter. + const labelsToUse = + filter?.labels && filter.labels.length > 0 ? filter.labels : this.labels; + if (labelsToUse.length > 0) { + args.push('--label', labelsToUse[0]!); + } + + const { stdout, exitCode, stderr } = await execBv( + args, + this.workingDir + ); + + if (exitCode !== 0) { + console.error('bv --robot-next failed:', stderr); + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + let nextOutputRaw: unknown; + try { + nextOutputRaw = JSON.parse(stdout) as BvRobotNextOutput; + } catch (err) { + console.error('Failed to parse bv --robot-next output:', err); + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + if (hasMessageField(nextOutputRaw)) { + // No actionable items. + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + if (!hasValidTaskId(nextOutputRaw)) { + console.error( + 'Invalid bv --robot-next output (missing task id):', + nextOutputRaw + ); + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + const nextOutput = nextOutputRaw as BvRobotNextTask; + + // Check epic membership: if an epic filter is active, ensure bv's pick + // belongs to it, otherwise fall back. + const epicFilter = filter?.parentId; + if (epicFilter) { + const fullTask = await this.delegate.getTask(nextOutput.id); + if (!fullTask || fullTask.parentId !== epicFilter) { + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + if (statusFilter && !statusFilter.includes(fullTask.status)) { + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + // Augment and return. + fullTask.metadata = { + ...fullTask.metadata, + bvScore: nextOutput.score, + bvReasons: nextOutput.reasons, + bvUnblocks: nextOutput.unblocks, + }; + this.scheduleTriageRefresh(); + return fullTask; + } + + // Schedule background triage refresh for metadata enrichment. + this.scheduleTriageRefresh(); + + // Fetch full task details. + const fullTask = await this.delegate.getTask(nextOutput.id); + if (fullTask) { + if (statusFilter && !statusFilter.includes(fullTask.status)) { + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + fullTask.metadata = { + ...fullTask.metadata, + bvScore: nextOutput.score, + bvReasons: nextOutput.reasons, + bvUnblocks: nextOutput.unblocks, + }; + return fullTask; + } + + if (statusFilter && !statusFilter.includes('open')) { + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + + // Fallback: construct minimal task from robot-next output. + return { + id: nextOutput.id, + title: nextOutput.title, + status: 'open' as TrackerTaskStatus, + priority: 2, + metadata: { + bvScore: nextOutput.score, + bvReasons: nextOutput.reasons, + bvUnblocks: nextOutput.unblocks, + }, + }; + } catch (err) { + console.error('Error in BeadsRustBvTrackerPlugin.getNextTask:', err); + return this.delegateGetNextTaskFiltered(filter, statusFilter); + } + } + + override async completeTask( + id: string, + reason?: string + ): Promise { + const result = await this.delegate.completeTask(id, reason); + + if (result.success && this.bvAvailable) { + this.scheduleTriageRefresh(true); + } + + return result; + } + + override async updateTaskStatus( + id: string, + status: TrackerTaskStatus + ): Promise { + const result = await this.delegate.updateTaskStatus(id, status); + + if (result && this.bvAvailable) { + this.scheduleTriageRefresh(true); + } + + return result; + } + + override async isComplete(filter?: TaskFilter): Promise { + return this.delegate.isComplete(filter); + } + + override async sync(): Promise { + return this.delegate.sync(); + } + + override async isTaskReady(id: string): Promise { + return this.delegate.isTaskReady(id); + } + + override async getEpics(): Promise { + return this.delegate.getEpics(); + } + + setEpicId(epicId: string): void { + this.delegate.setEpicId(epicId); + } + + override getSetupQuestions(): SetupQuestion[] { + return this.delegate.getSetupQuestions(); + } + + override async validateSetup( + answers: Record + ): Promise { + const brValidation = await this.delegate.validateSetup(answers); + if (brValidation) { + return brValidation; + } + + const detection = await this.detect(); + if (!detection.bvAvailable) { + console.warn( + 'Warning: bv binary not found. Smart task selection will fall back to br behavior.' + ); + } + + return null; + } + + override async dispose(): Promise { + await this.delegate.dispose(); + await super.dispose(); + } + + // Delegate getPrdContext if available. + async getPrdContext(): Promise<{ + name: string; + description?: string; + content: string; + completedCount: number; + totalCount: number; + } | null> { + return this.delegate.getPrdContext(); + } + + /** + * Check whether bv is available for smart task selection. + */ + isBvAvailable(): boolean { + return this.bvAvailable; + } + + /** + * Force a refresh of bv triage data. + */ + async refreshTriage(): Promise { + if (!this.bvAvailable) { + return; + } + + const args = ['--robot-triage']; + if (this.labels.length > 0) { + args.push('--label', this.labels[0]!); + } + + const { stdout, exitCode } = await execBv(args, this.workingDir); + + if (exitCode === 0) { + try { + const parsed = JSON.parse(stdout) as BvTriageOutput; + if ( + parsed && + typeof parsed === 'object' && + parsed.triage && + typeof parsed.triage === 'object' && + Array.isArray(parsed.triage.recommendations) + ) { + // Filter out any malformed items within the recommendations array. + parsed.triage.recommendations = parsed.triage.recommendations.filter( + (rec): rec is BvTriageRecommendation => + typeof rec === 'object' && + rec !== null && + 'id' in rec && + typeof (rec as BvTriageRecommendation).id === 'string' + ); + this.lastTriageOutput = parsed; + // Only update timestamp when we got valid, parsed data + // so parse failures don't suppress retries via the rate limiter. + this.lastTriageRefreshAt = Date.now(); + } else { + this.lastTriageOutput = { triage: { recommendations: [] } }; + } + } catch { + // Ignore parse errors — fall back to empty recommendations. + this.lastTriageOutput = { triage: { recommendations: [] } }; + } + } else { + // Non-zero exit: treat as empty to avoid stale data. + this.lastTriageOutput = { triage: { recommendations: [] } }; + } + } + + override getTemplate(): string { + return BEADS_RUST_BV_TEMPLATE; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private normalizeStatusFilter( + statusFilter: TaskFilter['status'] + ): TrackerTaskStatus[] | undefined { + if (statusFilter === undefined) { + return undefined; + } + + return Array.isArray(statusFilter) ? statusFilter : [statusFilter]; + } + + /** + * Delegate to beads-rust getNextTask, then validate the returned task's + * status against statusFilter. Returns undefined if no matching task. + */ + private async delegateGetNextTaskFiltered( + filter?: TaskFilter, + statusFilter?: TrackerTaskStatus[] + ): Promise { + const task = await this.delegate.getNextTask(filter); + if (task && statusFilter && !statusFilter.includes(task.status)) { + return undefined; + } + return task; + } + + private scheduleTriageRefresh(force = false): void { + if (!this.bvAvailable) { + return; + } + + if (this.triageRefreshInFlight) { + if (force) { + this.pendingForcedTriageRefresh = true; + } + return; + } + + const now = Date.now(); + if ( + !force && + this.lastTriageRefreshAt > 0 && + now - this.lastTriageRefreshAt < TRIAGE_REFRESH_MIN_INTERVAL_MS + ) { + return; + } + + this.triageRefreshInFlight = this.refreshTriage() + .catch((err) => { + console.error('Failed to refresh bv triage data:', err); + }) + .finally(() => { + this.triageRefreshInFlight = null; + + if (this.pendingForcedTriageRefresh) { + this.pendingForcedTriageRefresh = false; + this.scheduleTriageRefresh(true); + } + }); + } +} + +/** + * Factory function for the Beads-Rust + bv tracker plugin. + */ +const createBeadsRustBvTracker: TrackerPluginFactory = () => + new BeadsRustBvTrackerPlugin(); + +export default createBeadsRustBvTracker; diff --git a/src/plugins/trackers/builtin/index.ts b/src/plugins/trackers/builtin/index.ts index 4a4dafb6..d3313c6c 100644 --- a/src/plugins/trackers/builtin/index.ts +++ b/src/plugins/trackers/builtin/index.ts @@ -9,6 +9,7 @@ import createJsonTracker from './json/index.js'; import createBeadsTracker from './beads/index.js'; import createBeadsBvTracker from './beads-bv/index.js'; import createBeadsRustTracker from './beads-rust/index.js'; +import createBeadsRustBvTracker from './beads-rust-bv/index.js'; /** * All built-in tracker plugin factories. @@ -18,6 +19,7 @@ export const builtinTrackers = { beads: createBeadsTracker, 'beads-bv': createBeadsBvTracker, 'beads-rust': createBeadsRustTracker, + 'beads-rust-bv': createBeadsRustBvTracker, } as const; /** @@ -37,4 +39,5 @@ export { createBeadsTracker, createBeadsBvTracker, createBeadsRustTracker, + createBeadsRustBvTracker, }; diff --git a/src/templates/builtin.ts b/src/templates/builtin.ts index 8ab986c7..d5eb5c68 100644 --- a/src/templates/builtin.ts +++ b/src/templates/builtin.ts @@ -313,6 +313,105 @@ When finished (or if already complete), signal completion with: COMPLETE `; +/** + * Beads-rust + bv tracker template - uses br commands with bv selection context. + * Context-first structure: PRD → Selection Context → Patterns → Task → Workflow + */ +export const BEADS_RUST_BV_TEMPLATE = `{{!-- Full PRD for project context (agent studies this first) --}} +{{#if prdContent}} +## PRD: {{prdName}} +{{#if prdDescription}} +{{prdDescription}} +{{/if}} + +### Progress: {{prdCompletedCount}}/{{prdTotalCount}} tasks complete + +
+Full PRD Document (click to expand) + +{{prdContent}} + +
+{{/if}} + +{{!-- Why this task was selected (bv context) --}} +{{#if selectionReason}} +## Why This Task Was Selected +{{selectionReason}} +{{/if}} + +{{!-- Learnings from previous iterations (patterns first) --}} +{{#if codebasePatterns}} +## Codebase Patterns (Study These First) +{{codebasePatterns}} +{{/if}} + +## Bead Details +- **ID**: {{taskId}} +- **Title**: {{taskTitle}} +{{#if epicId}} +- **Epic**: {{epicId}}{{#if epicTitle}} - {{epicTitle}}{{/if}} +{{/if}} +{{#if taskDescription}} +- **Description**: {{taskDescription}} +{{/if}} + +{{#if acceptanceCriteria}} +## Acceptance Criteria +{{acceptanceCriteria}} +{{/if}} + +{{#if dependsOn}} +## Dependencies +This task depends on: {{dependsOn}} +{{/if}} + +{{#if blocks}} +## Impact +Completing this task will unblock: {{blocks}} +{{/if}} + +{{#if recentProgress}} +## Recent Progress +{{recentProgress}} +{{/if}} + +## Workflow +1. Study the PRD context above to understand the bigger picture (if available) +2. Study \`.ralph-tui/progress.md\` to understand overall status, implementation progress, and learnings including codebase patterns and gotchas +3. Implement the requirements (stay on current branch) +4. Run your project's quality checks (typecheck, lint, etc.) +{{#if config.autoCommit}} +5. Do NOT create git commits. Changes will be committed automatically by the engine after task completion. +{{else}} +5. Do NOT create git commits. Leave all changes uncommitted for manual review. +{{/if}} +6. Close the bead: \`br close {{taskId}} --reason "Brief description"\` +7. Flush tracker state to JSONL (no git side effects): \`br sync --flush-only\` +8. Document learnings (see below) +9. Signal completion + +## Before Completing +APPEND to \`.ralph-tui/progress.md\`: +\`\`\` +## [Date] - {{taskId}} +- What was implemented +- Files changed +- **Learnings:** + - Patterns discovered + - Gotchas encountered +--- +\`\`\` + +If you discovered a **reusable pattern**, also add it to the \`## Codebase Patterns\` section at the TOP of progress.md. + +## Stop Condition +**IMPORTANT**: If the work is already complete (implemented in a previous iteration or already exists), verify it works correctly and signal completion immediately. + +When finished (or if already complete), signal completion with: +COMPLETE +`; + /** * JSON (prd.json) tracker template - structured for PRD user stories. * Context-first structure: PRD → Patterns → Task → Workflow diff --git a/src/templates/engine.ts b/src/templates/engine.ts index dc7b3213..75d0ff1a 100644 --- a/src/templates/engine.ts +++ b/src/templates/engine.ts @@ -21,6 +21,7 @@ import { BEADS_TEMPLATE, BEADS_RUST_TEMPLATE, BEADS_BV_TEMPLATE, + BEADS_RUST_BV_TEMPLATE, JSON_TEMPLATE, } from './builtin.js'; @@ -42,6 +43,8 @@ export function getBuiltinTemplate(trackerType: BuiltinTemplateType): string { return BEADS_RUST_TEMPLATE; case 'beads-bv': return BEADS_BV_TEMPLATE; + case 'beads-rust-bv': + return BEADS_RUST_BV_TEMPLATE; case 'json': return JSON_TEMPLATE; case 'default': @@ -56,6 +59,9 @@ export function getBuiltinTemplate(trackerType: BuiltinTemplateType): string { * @returns The matching built-in template type */ export function getTemplateTypeFromPlugin(pluginName: string): BuiltinTemplateType { + if (pluginName.includes('beads-rust-bv')) { + return 'beads-rust-bv'; + } if (pluginName.includes('beads-bv')) { return 'beads-bv'; } @@ -643,6 +649,7 @@ export function installBuiltinTemplates(force = false): { 'default': DEFAULT_TEMPLATE, 'beads': BEADS_TEMPLATE, 'beads-bv': BEADS_BV_TEMPLATE, + 'beads-rust-bv': BEADS_RUST_BV_TEMPLATE, 'json': JSON_TEMPLATE, }; diff --git a/src/templates/index.ts b/src/templates/index.ts index 7b657275..8ce580e2 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -33,5 +33,6 @@ export { DEFAULT_TEMPLATE, BEADS_TEMPLATE, BEADS_BV_TEMPLATE, + BEADS_RUST_BV_TEMPLATE, JSON_TEMPLATE, } from './builtin.js'; diff --git a/src/templates/types.ts b/src/templates/types.ts index b5b73fba..8da7006e 100644 --- a/src/templates/types.ts +++ b/src/templates/types.ts @@ -162,7 +162,7 @@ export interface TemplateRenderResult { * Supported built-in template types. * Each tracker type has a corresponding default template. */ -export type BuiltinTemplateType = 'default' | 'beads' | 'beads-rust' | 'json' | 'beads-bv'; +export type BuiltinTemplateType = 'default' | 'beads' | 'beads-rust' | 'json' | 'beads-bv' | 'beads-rust-bv'; /** * Template configuration in ralph config. diff --git a/tests/plugins/beads-rust-bv-tracker.test.ts b/tests/plugins/beads-rust-bv-tracker.test.ts new file mode 100644 index 00000000..29d880b0 --- /dev/null +++ b/tests/plugins/beads-rust-bv-tracker.test.ts @@ -0,0 +1,631 @@ +/** + * ABOUTME: Tests for BeadsRustBvTrackerPlugin. + * Covers metadata, initialization, detect(), getNextTask() (bv available/unavailable, + * fallback cases), getTasks() decoration, completeTask(), updateTaskStatus(), + * getSetupQuestions(), validateSetup(), and error-handling paths. + * + * Uses mock.module() to intercept node:child_process.spawn before dynamic import, + * ensuring ES module mocking works correctly with bun:test. + */ + +import { + describe, + test, + expect, + mock, + beforeAll, + afterAll, + beforeEach, +} from 'bun:test'; +import { EventEmitter } from 'node:events'; +import type { TrackerTask } from '../../src/plugins/trackers/types.js'; + +let BeadsRustBvTrackerPlugin: typeof import('../../src/plugins/trackers/builtin/beads-rust-bv/index.js').BeadsRustBvTrackerPlugin; +let BeadsRustTrackerPlugin: typeof import('../../src/plugins/trackers/builtin/beads-rust/index.js').BeadsRustTrackerPlugin; + +interface MockSpawnResponse { + command: string; + stdout?: string; + stderr?: string; + exitCode?: number; +} + +const spawnResponses: MockSpawnResponse[] = []; +const capturedSpawns: { command: string; args: string[] }[] = []; + +function queueSpawnResponse(response: MockSpawnResponse): void { + spawnResponses.push(response); +} + +describe('BeadsRustBvTrackerPlugin', () => { + beforeAll(async () => { + mock.module('node:child_process', () => ({ + spawn: (command: string, args: string[] = []) => { + capturedSpawns.push({ command, args }); + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + + const matchIndex = spawnResponses.findIndex( + (r) => r.command === command || r.command === '*' + ); + const response = + matchIndex >= 0 + ? spawnResponses.splice(matchIndex, 1)[0] + : { command, exitCode: 0 }; + + setTimeout(() => { + if (response?.stdout) { + proc.stdout.emit('data', Buffer.from(response.stdout)); + } + if (response?.stderr) { + proc.stderr.emit('data', Buffer.from(response.stderr)); + } + proc.emit('close', response?.exitCode ?? 0); + }, 0); + + return proc; + }, + })); + + mock.module('node:fs/promises', () => ({ + access: async () => { }, + readFile: async () => '', + })); + + const module = await import('../../src/plugins/trackers/builtin/beads-rust-bv/index.js'); + BeadsRustBvTrackerPlugin = module.BeadsRustBvTrackerPlugin; + const rustModule = await import('../../src/plugins/trackers/builtin/beads-rust/index.js'); + BeadsRustTrackerPlugin = rustModule.BeadsRustTrackerPlugin; + }); + + afterAll(() => { + mock.restore(); + }); + + beforeEach(() => { + spawnResponses.length = 0; + capturedSpawns.length = 0; + }); + + // --------------------------------------------------------------------------- + // 4.2 Plugin metadata + // --------------------------------------------------------------------------- + describe('meta', () => { + test('has correct plugin id', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.meta.id).toBe('beads-rust-bv'); + }); + + test('name contains Beads and Smart', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.meta.name).toContain('Beads'); + expect(plugin.meta.name).toContain('Smart'); + }); + + test('description mentions bv', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.meta.description).toContain('bv'); + }); + + test('version is semver format', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.meta.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + test('capabilities flags are all true', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.meta.supportsBidirectionalSync).toBe(true); + expect(plugin.meta.supportsHierarchy).toBe(true); + expect(plugin.meta.supportsDependencies).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // 4.3 initialize() + // --------------------------------------------------------------------------- + describe('initialize()', () => { + test('bvAvailable is false before initialization', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + expect(plugin.isBvAvailable()).toBe(false); + }); + + test('sets bvAvailable=true when br and bv are both available', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + // br --version → success + queueSpawnResponse({ command: 'br', stdout: 'br version 1.0.0', exitCode: 0 }); + // bv --version → success + queueSpawnResponse({ command: 'bv', stdout: 'bv 0.5.0', exitCode: 0 }); + + await plugin.initialize({ workingDir: '/tmp/project' }); + expect(plugin.isBvAvailable()).toBe(true); + }); + + test('sets bvAvailable=false when bv is not available', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + // br --version → success + queueSpawnResponse({ command: 'br', stdout: 'br version 1.0.0', exitCode: 0 }); + // bv --version → failure + queueSpawnResponse({ command: 'bv', exitCode: 1, stderr: 'not found' }); + + await plugin.initialize({ workingDir: '/tmp/project' }); + expect(plugin.isBvAvailable()).toBe(false); + }); + + test('ready is false when br is unavailable', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + // Stub the delegate's detect so it reports br unavailable. + (plugin as unknown as { delegate: { detect: () => Promise<{ available: boolean; brVersion: undefined }> } }).delegate.detect = + async () => ({ available: false, brVersion: undefined }); + + await plugin.initialize({ workingDir: '/tmp/project' }); + expect(await plugin.isReady()).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 4.4 detect() + // --------------------------------------------------------------------------- + describe('detect()', () => { + test('reports available and bvAvailable when both binaries present', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + queueSpawnResponse({ command: 'br', stdout: 'br version 1.0.0', exitCode: 0 }); + queueSpawnResponse({ command: 'bv', stdout: 'bv 0.5.0', exitCode: 0 }); + + const result = await plugin.detect(); + expect(result.available).toBe(true); + expect(result.bvAvailable).toBe(true); + }); + + test('reports available=true, bvAvailable=false when bv missing', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + queueSpawnResponse({ command: 'br', stdout: 'br version 1.0.0', exitCode: 0 }); + queueSpawnResponse({ command: 'bv', exitCode: 1, stderr: 'not found' }); + + const result = await plugin.detect(); + expect(result.available).toBe(true); + expect(result.bvAvailable).toBe(false); + }); + + test('reports available=false when br missing', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + queueSpawnResponse({ command: 'br', exitCode: 1, stderr: 'not found' }); + + const result = await plugin.detect(); + expect(result.available).toBe(false); + expect(result.bvAvailable).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 4.5 getNextTask() + // --------------------------------------------------------------------------- + describe('getNextTask()', () => { + function makePlugin(bvAvailable = false): BeadsRustBvTrackerPlugin { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = bvAvailable; + // Suppress background triage refresh in unit tests. + (plugin as unknown as { scheduleTriageRefresh: () => void }).scheduleTriageRefresh = () => { }; + return plugin; + } + + test('delegates to br when bv is unavailable', async () => { + const plugin = makePlugin(false); + const brTask: TrackerTask = { id: 'br-1', title: 'br task', status: 'open', priority: 2 }; + + // Stub delegate.getNextTask + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + const result = await plugin.getNextTask(); + expect(result).toEqual(brTask); + }); + + test('augments task with bv metadata when bv returns a task', async () => { + const plugin = makePlugin(true); + const fullTask: TrackerTask = { id: 'task-42', title: 'Task 42', status: 'open', priority: 1 }; + + (plugin as unknown as { delegate: { getTask: (id: string) => Promise } }).delegate.getTask = + async () => fullTask; + + queueSpawnResponse({ + command: 'bv', + stdout: JSON.stringify({ + id: 'task-42', + title: 'Task 42', + score: 0.85, + reasons: ['Critical path', 'Unblocks 3'], + unblocks: 3, + claim_command: 'br update task-42', + show_command: 'br show task-42', + }), + exitCode: 0, + }); + + const result = await plugin.getNextTask(); + expect(result?.id).toBe('task-42'); + expect(result?.metadata?.bvScore).toBe(0.85); + expect(result?.metadata?.bvReasons).toEqual(['Critical path', 'Unblocks 3']); + expect(result?.metadata?.bvUnblocks).toBe(3); + }); + + test('falls back when bv returns { message } (no actionable items)', async () => { + const plugin = makePlugin(true); + const brTask: TrackerTask = { id: 'fallback', title: 'Fallback', status: 'open', priority: 2 }; + + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + queueSpawnResponse({ + command: 'bv', + stdout: JSON.stringify({ message: 'No actionable items available' }), + exitCode: 0, + }); + + const result = await plugin.getNextTask(); + expect(result).toEqual(brTask); + }); + + test('falls back when bv exits non-zero', async () => { + const plugin = makePlugin(true); + const brTask: TrackerTask = { id: 'fallback', title: 'Fallback', status: 'open', priority: 2 }; + + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + queueSpawnResponse({ command: 'bv', exitCode: 1, stderr: 'crash' }); + + const result = await plugin.getNextTask(); + expect(result).toEqual(brTask); + }); + + test('falls back when bv output is invalid JSON', async () => { + const plugin = makePlugin(true); + const brTask: TrackerTask = { id: 'fallback', title: 'Fallback', status: 'open', priority: 2 }; + + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + queueSpawnResponse({ command: 'bv', stdout: 'not-json', exitCode: 0 }); + + const result = await plugin.getNextTask(); + expect(result).toEqual(brTask); + }); + + test('falls back when bv pick is outside the epic', async () => { + const plugin = makePlugin(true); + const brTask: TrackerTask = { id: 'epic-child', title: 'Epic child', status: 'open', priority: 2 }; + const wrongTask: TrackerTask = { + id: 'outside-task', + title: 'Wrong task', + status: 'open', + priority: 2, + parentId: 'other-epic', + }; + + (plugin as unknown as { delegate: { getTask: (id: string) => Promise } }).delegate.getTask = + async () => wrongTask; + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + queueSpawnResponse({ + command: 'bv', + stdout: JSON.stringify({ + id: 'outside-task', + title: 'Wrong task', + score: 0.9, + reasons: ['high score'], + unblocks: 1, + claim_command: '', + show_command: '', + }), + exitCode: 0, + }); + + const result = await plugin.getNextTask({ parentId: 'my-epic' }); + expect(result).toEqual(brTask); + }); + + test('forwards label filter to bv', async () => { + const plugin = makePlugin(true); + + // Override execBv by intercepting spawn + queueSpawnResponse({ + command: 'bv', + stdout: JSON.stringify({ message: 'No actionable items' }), + exitCode: 0, + }); + + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => undefined; + + await plugin.getNextTask({ labels: ['backend'] }); + + // Assert that bv was spawned with the correct arguments + const bvSpawn = capturedSpawns.find(s => s.command === 'bv' && s.args.includes('--robot-next')); + expect(bvSpawn).toBeDefined(); + expect(bvSpawn?.args).toContain('--label'); + expect(bvSpawn?.args).toContain('backend'); + }); + }); + + // --------------------------------------------------------------------------- + // 4.6 getTasks() + // --------------------------------------------------------------------------- + describe('getTasks()', () => { + test('decorates tasks with bv metadata when triage data is available', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + const tasks: TrackerTask[] = [ + { id: 'task-1', title: 'Task 1', status: 'open', priority: 2 }, + { id: 'task-2', title: 'Task 2', status: 'open', priority: 2 }, + ]; + (plugin as unknown as { delegate: { getTasks: () => Promise } }).delegate.getTasks = + async () => tasks.map((t) => ({ ...t })); + + (plugin as unknown as { lastTriageOutput: unknown }).lastTriageOutput = { + triage: { + recommendations: [ + { id: 'task-1', score: 0.9, reasons: ['Top pick'], unblocks: 4 }, + ], + }, + }; + + const result = await plugin.getTasks(); + const t1 = result.find((t) => t.id === 'task-1'); + const t2 = result.find((t) => t.id === 'task-2'); + + expect(t1?.metadata?.bvScore).toBe(0.9); + expect(t1?.metadata?.bvReasons).toEqual(['Top pick']); + expect(t1?.metadata?.bvUnblocks).toBe(4); + expect(t2?.metadata?.bvScore).toBeUndefined(); + }); + + test('returns tasks without bv metadata when no triage data', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + // lastTriageOutput remains null + + const tasks: TrackerTask[] = [ + { id: 'task-1', title: 'Task 1', status: 'open', priority: 2 }, + ]; + (plugin as unknown as { delegate: { getTasks: () => Promise } }).delegate.getTasks = + async () => tasks.map((t) => ({ ...t })); + + const result = await plugin.getTasks(); + expect(result[0]?.metadata).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // 4.7 completeTask() + // --------------------------------------------------------------------------- + describe('completeTask()', () => { + test('schedules triage refresh on success when bv is available', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + let refreshCalled = false; + (plugin as unknown as { scheduleTriageRefresh: (force?: boolean) => void }).scheduleTriageRefresh = + () => { refreshCalled = true; }; + + (plugin as unknown as { delegate: { completeTask: (id: string) => Promise<{ success: boolean; message: string }> } }).delegate.completeTask = + async () => ({ success: true, message: 'done' }); + + await plugin.completeTask('task-1'); + expect(refreshCalled).toBe(true); + }); + + test('does not schedule refresh when completeTask fails', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + let refreshCalled = false; + (plugin as unknown as { scheduleTriageRefresh: (force?: boolean) => void }).scheduleTriageRefresh = + () => { refreshCalled = true; }; + + (plugin as unknown as { delegate: { completeTask: (id: string) => Promise<{ success: boolean; message: string }> } }).delegate.completeTask = + async () => ({ success: false, message: 'failed' }); + + await plugin.completeTask('task-1'); + expect(refreshCalled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 4.8 updateTaskStatus() + // --------------------------------------------------------------------------- + describe('updateTaskStatus()', () => { + test('schedules triage refresh when status update returns a task', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + let refreshCalled = false; + (plugin as unknown as { scheduleTriageRefresh: (force?: boolean) => void }).scheduleTriageRefresh = + () => { refreshCalled = true; }; + + const updated: TrackerTask = { id: 'task-1', title: 'Task 1', status: 'in_progress', priority: 2 }; + (plugin as unknown as { delegate: { updateTaskStatus: (id: string, status: string) => Promise } }).delegate.updateTaskStatus = + async () => updated; + + await plugin.updateTaskStatus('task-1', 'in_progress'); + expect(refreshCalled).toBe(true); + }); + + test('does not schedule refresh when update returns undefined', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + let refreshCalled = false; + (plugin as unknown as { scheduleTriageRefresh: (force?: boolean) => void }).scheduleTriageRefresh = + () => { refreshCalled = true; }; + + (plugin as unknown as { delegate: { updateTaskStatus: (id: string, status: string) => Promise } }).delegate.updateTaskStatus = + async () => undefined; + + await plugin.updateTaskStatus('task-1', 'in_progress'); + expect(refreshCalled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // 4.9 getSetupQuestions() + // --------------------------------------------------------------------------- + describe('getSetupQuestions()', () => { + test('returns an array', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + const questions = plugin.getSetupQuestions(); + expect(Array.isArray(questions)).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // 4.10 validateSetup() + // --------------------------------------------------------------------------- + describe('validateSetup()', () => { + test('returns null (valid) when br is configured correctly', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + // Stub delegate.validateSetup → null + (plugin as unknown as { delegate: { validateSetup: (a: Record) => Promise } }).delegate.validateSetup = + async () => null; + // Stub detect → bv available + plugin.detect = async () => ({ available: true, bvAvailable: true }); + + const result = await plugin.validateSetup({}); + expect(result).toBeNull(); + }); + + test('warns (does not error) when bv is absent', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + (plugin as unknown as { delegate: { validateSetup: (a: Record) => Promise } }).delegate.validateSetup = + async () => null; + // Stub detect → bv missing + plugin.detect = async () => ({ available: true, bvAvailable: false }); + + // Should not throw, should return null (warning only) + const result = await plugin.validateSetup({}); + expect(result).toBeNull(); + }); + + test('returns error from delegate when br setup is invalid', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + + (plugin as unknown as { delegate: { validateSetup: (a: Record) => Promise } }).delegate.validateSetup = + async () => 'br not configured'; + + const result = await plugin.validateSetup({}); + expect(result).toBe('br not configured'); + }); + }); + + // --------------------------------------------------------------------------- + // 4.11 Error handling + // --------------------------------------------------------------------------- + describe('error handling in getNextTask()', () => { + test('returns delegate result without throwing when bv throws unexpectedly', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + (plugin as unknown as { bvAvailable: boolean }).bvAvailable = true; + + const brTask: TrackerTask = { id: 'safe', title: 'Safe task', status: 'open', priority: 2 }; + (plugin as unknown as { delegate: { getNextTask: () => Promise } }).delegate.getNextTask = + async () => brTask; + + // Make spawn throw hard by exhausting the queue (spawn returns exitCode: 0 with empty stdout) + // but make getTask throw to simulate an unexpected error mid-flow. + queueSpawnResponse({ + command: 'bv', + stdout: JSON.stringify({ + id: 'bad-task', + title: 'Bad', + score: 0.5, + reasons: [], + unblocks: 0, + claim_command: '', + show_command: '', + }), + exitCode: 0, + }); + + (plugin as unknown as { delegate: { getTask: (id: string) => Promise } }).delegate.getTask = + async () => { throw new Error('unexpected failure'); }; + (plugin as unknown as { scheduleTriageRefresh: () => void }).scheduleTriageRefresh = () => { }; + + const result = await plugin.getNextTask(); + // Should fall back gracefully + expect(result).toEqual(brTask); + }); + }); + + // --------------------------------------------------------------------------- + // Template test + // --------------------------------------------------------------------------- + describe('getTemplate()', () => { + test('returns template with br commands', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + const tpl = plugin.getTemplate(); + expect(tpl).toContain('br close'); + expect(tpl).toContain('br sync'); + }); + + test('template includes selectionReason block', () => { + const plugin = new BeadsRustBvTrackerPlugin(); + const tpl = plugin.getTemplate(); + expect(tpl).toContain('selectionReason'); + expect(tpl).toContain('Why This Task Was Selected'); + }); + }); + + // --------------------------------------------------------------------------- + // scheduleTriageRefresh deduplication + // --------------------------------------------------------------------------- + describe('scheduleTriageRefresh', () => { + test('queues a forced refresh while a refresh is already in-flight', async () => { + const plugin = new BeadsRustBvTrackerPlugin(); + const state = plugin as unknown as { + bvAvailable: boolean; + scheduleTriageRefresh: (force?: boolean) => void; + refreshTriage: () => Promise; + triageRefreshInFlight: Promise | null; + }; + + state.bvAvailable = true; + + let refreshCalls = 0; + let releaseFirst!: () => void; + const gate = new Promise((resolve) => { releaseFirst = resolve; }); + + state.refreshTriage = async () => { + refreshCalls += 1; + if (refreshCalls === 1) await gate; + }; + + state.scheduleTriageRefresh(); + state.scheduleTriageRefresh(true); + + expect(refreshCalls).toBe(1); + expect(state.triageRefreshInFlight).not.toBeNull(); + + releaseFirst(); + + for (let i = 0; i < 20; i++) { + if (refreshCalls === 2 && state.triageRefreshInFlight === null) break; + await new Promise((r) => setTimeout(r, 5)); + } + + expect(refreshCalls).toBe(2); + expect(state.triageRefreshInFlight).toBeNull(); + }); + }); +}); diff --git a/tests/templates/engine.test.ts b/tests/templates/engine.test.ts index a71fff62..ac186c20 100644 --- a/tests/templates/engine.test.ts +++ b/tests/templates/engine.test.ts @@ -786,17 +786,17 @@ describe('Template Engine - Installation', () => { mock.restore(); }); - test('installs all four builtin templates', () => { + test('installs all five builtin templates', () => { // Use fresh import to bypass mock pollution from migration-install.test.ts // Note: Cannot use sandboxed testDir here because fresh import uses real getUserConfigDir const result = freshInstallBuiltinTemplates(false); - // The function returns results for all four templates (not undefined) - // Templates: default, beads, beads-bv, json + // The function returns results for all five templates (not undefined) + // Templates: default, beads, beads-bv, beads-rust-bv, json // Note: beads-rust template exists but is not included in installBuiltinTemplates yet expect(result).toBeDefined(); expect(result.results).toBeDefined(); - expect(result.results.length).toBe(4); + expect(result.results.length).toBe(5); expect(result.templatesDir).toContain('.config/ralph-tui/templates'); // Verify templates were actually created or skipped (both are valid outcomes) expect(result.results.every(r => r.created || r.skipped)).toBe(true); diff --git a/website/content/docs/plugins/trackers/beads-rust-bv.mdx b/website/content/docs/plugins/trackers/beads-rust-bv.mdx new file mode 100644 index 00000000..8b96e97d --- /dev/null +++ b/website/content/docs/plugins/trackers/beads-rust-bv.mdx @@ -0,0 +1,215 @@ +--- +title: Beads-Rust-BV Tracker +description: Smart task selection using bv graph analysis (PageRank, critical path) with the br CLI for high-performance Beads tracking. +--- + +# Beads-Rust-BV Tracker + +The **Beads-Rust-BV** tracker combines the high-performance Rust backend (`br`) with intelligent, graph-aware task ordering from the `bv` tool. It is the recommended choice for teams already using `beads-rust` who want optimal task sequencing based on dependency graph analysis. + +When `bv` is available, it uses `bv --robot-next` to select the task that maximises throughput (accounting for PageRank, critical path, and unblock potential). If `bv` is not installed, it seamlessly falls back to standard `beads-rust` behaviour (`br ready`). + +## Prerequisites + + +Install the `br` (beads-rust) CLI — see the [Beads-Rust tracker docs](/docs/plugins/trackers/beads-rust) for platform-specific installation instructions. + + + +Create and initialise a Beads project with `br`: + +```bash +br init +``` + + + + + +Install the `bv` (beads-viewer) CLI for smart task selection: + +```bash +cargo install bv +# or via the project's install script +curl -fsSL https://get.beads.sh/bv | sh +``` + + + + + +Verify both tools are on your `PATH`: + +```bash +br --version # e.g. br 1.0.0 +bv --version # e.g. bv 0.5.0 +``` + + + + +## Basic Usage + + + + +Create an epic and child tasks using `br`: + +```bash +br new epic "Implement authentication" --label auth +br new task "Add login form" --parent auth-1 --label auth +br new task "Add JWT middleware" --parent auth-1 --label auth +br new task "Write auth tests" --parent auth-1 --depends-on auth-1.2 +``` + + + + + +Link dependencies so `bv` can analyse them: + +```bash +br link auth-1.2 blockedby auth-1.1 +br link auth-1.3 blockedby auth-1.2 +``` + + + + + +Run Ralph TUI with the `beads-rust-bv` tracker: + +```bash +ralph run --tracker beads-rust-bv +``` + +On each iteration, Ralph uses `bv --robot-next` to select the highest-value, unblocked task based on its dependency graph score. + + + + +## Configuration + +### CLI Flags + +```bash +ralph run \ + --tracker beads-rust-bv \ + --tracker-option epicId=auth-1 \ + --tracker-option labels=auth +``` + +### Config File (TOML) + +```toml +[tracker] +plugin = "beads-rust-bv" + +[tracker.options] +epicId = "auth-1" # optional: filter to a specific epic +labels = "auth,backend" # optional: comma-separated string OR array (e.g. ["auth", "backend"]) +workingDir = "./workspace" # optional: path to br project root +beadsDir = ".beads" # optional: beads storage directory (default: .beads) +``` + +## Options Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `epicId` | string | `""` | Restrict task selection to this epic's children. | +| `labels` | `string \| string[]` | `""` | Label filter. Accepts a comma-separated string (e.g. `"auth,backend"`) or a string array (e.g. `["auth", "backend"]`). | +| `workingDir` | string | `cwd` | Path to the project root where `.beads/` lives. | +| `beadsDir` | string | `".beads"` | Custom Beads storage directory name. | + +## How It Works + +On each task cycle, the plugin follows this sequence: + +``` +Initialise + └─ detect() → Check br binary + .beads/ dir + └─ detect() → Check bv binary (sets bvAvailable flag) + +getNextTask() + ├─ bvAvailable = false → br ready (same as beads-rust) + └─ bvAvailable = true + └─ bv --robot-next [--label