diff --git a/v2/src-tauri/src/bttask.rs b/v2/src-tauri/src/bttask.rs index e708b5b..71ba2d9 100644 --- a/v2/src-tauri/src/bttask.rs +++ b/v2/src-tauri/src/bttask.rs @@ -117,20 +117,110 @@ pub fn task_comments(task_id: &str) -> Result, String> { } /// Update task status +/// When transitioning to 'review', auto-posts to #review-queue channel if it exists pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> { let valid = ["todo", "progress", "review", "done", "blocked"]; if !valid.contains(&status) { return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid)); } let db = open_db()?; + + // Fetch task info before update (for channel notification) + let task_title: Option<(String, String)> = if status == "review" { + db.query_row( + "SELECT title, group_id FROM tasks WHERE id = ?1", + params![task_id], + |row| Ok((row.get::<_, String>("title")?, row.get::<_, String>("group_id")?)), + ).ok() + } else { + None + }; + db.execute( "UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2", params![status, task_id], ) .map_err(|e| format!("Update error: {e}"))?; + + // Auto-post to #review-queue channel on review transition + if let Some((title, group_id)) = task_title { + notify_review_channel(&db, &group_id, task_id, &title); + } + Ok(()) } +/// Post a notification to #review-queue channel (best-effort, never fails the parent operation) +fn notify_review_channel(db: &Connection, group_id: &str, task_id: &str, title: &str) { + // Find #review-queue channel for this group + let channel_id: Option = db + .query_row( + "SELECT id FROM channels WHERE name = 'review-queue' AND group_id = ?1", + params![group_id], + |row| row.get(0), + ) + .ok(); + + let channel_id = match channel_id { + Some(id) => id, + None => { + // Auto-create #review-queue channel + match ensure_review_channels(db, group_id) { + Some(id) => id, + None => return, // Give up silently + } + } + }; + + let msg_id = uuid::Uuid::new_v4().to_string(); + let content = format!("📋 Task ready for review: **{}** (`{}`)", title, task_id); + let _ = db.execute( + "INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?1, ?2, 'system', ?3)", + params![msg_id, channel_id, content], + ); +} + +/// Ensure #review-queue and #review-log channels exist for a group. +/// Returns the review-queue channel ID if created/found. +fn ensure_review_channels(db: &Connection, group_id: &str) -> Option { + // Create channels only if they don't already exist + for name in &["review-queue", "review-log"] { + let exists: bool = db + .query_row( + "SELECT COUNT(*) > 0 FROM channels WHERE name = ?1 AND group_id = ?2", + params![name, group_id], + |row| row.get(0), + ) + .unwrap_or(false); + if !exists { + let id = uuid::Uuid::new_v4().to_string(); + let _ = db.execute( + "INSERT INTO channels (id, name, group_id, created_by) VALUES (?1, ?2, ?3, 'system')", + params![id, name, group_id], + ); + } + } + + // Return the review-queue channel ID + db.query_row( + "SELECT id FROM channels WHERE name = 'review-queue' AND group_id = ?1", + params![group_id], + |row| row.get(0), + ) + .ok() +} + +/// Count tasks in 'review' status for a group +pub fn review_queue_count(group_id: &str) -> Result { + let db = open_db()?; + db.query_row( + "SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'", + params![group_id], + |row| row.get(0), + ) + .map_err(|e| format!("Query error: {e}")) +} + /// Add a comment to a task pub fn add_comment(task_id: &str, agent_id: &str, content: &str) -> Result { let db = open_db()?; @@ -201,6 +291,20 @@ mod tests { agent_id TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + group_id TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE channel_messages ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + from_agent TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) );", ) .unwrap(); @@ -362,4 +466,132 @@ mod tests { assert!(!valid.contains(&"invalid")); assert!(!valid.contains(&"cancelled")); } + + // ---- Review channel auto-creation ---- + + #[test] + fn test_ensure_review_channels_creates_both() { + let conn = test_db(); + let result = ensure_review_channels(&conn, "g1"); + assert!(result.is_some(), "should return review-queue channel ID"); + + // Verify both channels exist + let queue_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channels WHERE name = 'review-queue' AND group_id = 'g1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(queue_count, 1); + + let log_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channels WHERE name = 'review-log' AND group_id = 'g1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(log_count, 1); + } + + #[test] + fn test_ensure_review_channels_idempotent() { + let conn = test_db(); + let id1 = ensure_review_channels(&conn, "g1").unwrap(); + let id2 = ensure_review_channels(&conn, "g1").unwrap(); + assert_eq!(id1, id2, "should return same channel ID on repeated calls"); + + // Verify no duplicates + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channels WHERE name = 'review-queue' AND group_id = 'g1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_notify_review_channel_posts_message() { + let conn = test_db(); + // Insert a task + conn.execute( + "INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Fix login bug', 'admin', 'g1')", + [], + ).unwrap(); + + // Trigger notification (should auto-create channel) + notify_review_channel(&conn, "g1", "t1", "Fix login bug"); + + // Verify message was posted + let msg_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM channel_messages cm + JOIN channels c ON cm.channel_id = c.id + WHERE c.name = 'review-queue' AND c.group_id = 'g1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(msg_count, 1); + + // Verify message content + let content: String = conn + .query_row( + "SELECT cm.content FROM channel_messages cm + JOIN channels c ON cm.channel_id = c.id + WHERE c.name = 'review-queue'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(content.contains("Fix login bug")); + assert!(content.contains("t1")); + } + + // ---- Review queue count ---- + + #[test] + fn test_review_queue_count_via_sql() { + let conn = test_db(); + // Insert tasks with various statuses + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'A', 'review', 'admin', 'g1')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t2', 'B', 'review', 'admin', 'g1')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t3', 'C', 'progress', 'admin', 'g1')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t4', 'D', 'review', 'admin', 'g2')", + [], + ).unwrap(); + + // Count review tasks for g1 + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'", + params!["g1"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 2, "should count only review tasks in g1"); + + // Count review tasks for g2 + let count_g2: i64 = conn + .query_row( + "SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'", + params!["g2"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count_g2, 1, "should count only review tasks in g2"); + } } diff --git a/v2/src-tauri/src/commands/bttask.rs b/v2/src-tauri/src/commands/bttask.rs index 395c69a..663744a 100644 --- a/v2/src-tauri/src/commands/bttask.rs +++ b/v2/src-tauri/src/commands/bttask.rs @@ -36,3 +36,8 @@ pub fn bttask_create( pub fn bttask_delete(task_id: String) -> Result<(), String> { bttask::delete_task(&task_id) } + +#[tauri::command] +pub fn bttask_review_queue_count(group_id: String) -> Result { + bttask::review_queue_count(&group_id) +} diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 91bd9ad..461e8e0 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -147,6 +147,7 @@ pub fn run() { commands::bttask::bttask_add_comment, commands::bttask::bttask_create, commands::bttask::bttask_delete, + commands::bttask::bttask_review_queue_count, // Misc commands::misc::cli_get_group, commands::misc::open_url, diff --git a/v2/src/lib/adapters/bttask-bridge.ts b/v2/src/lib/adapters/bttask-bridge.ts index d0ce775..e195e84 100644 --- a/v2/src/lib/adapters/bttask-bridge.ts +++ b/v2/src/lib/adapters/bttask-bridge.ts @@ -56,3 +56,8 @@ export async function createTask( export async function deleteTask(taskId: string): Promise { return invoke('bttask_delete', { taskId }); } + +/** Count tasks currently in 'review' status for a group */ +export async function reviewQueueCount(groupId: GroupId): Promise { + return invoke('bttask_review_queue_count', { groupId }); +} diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index 1cf321b..4d3bab2 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -22,6 +22,8 @@ import { ProjectId, type AgentId, type GroupId } from '../../types/ids'; import { notify, dismissNotification } from '../../stores/notifications.svelte'; import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte'; + import { setReviewQueueDepth } from '../../stores/health.svelte'; + import { reviewQueueCount } from '../../adapters/bttask-bridge'; interface Props { project: ProjectConfig; @@ -93,6 +95,23 @@ }; }); + // Poll review queue depth for reviewer agents (feeds into attention scoring) + $effect(() => { + if (!(project.isAgent && project.agentRole === 'reviewer')) return; + const groupId = activeGroup?.id; + if (!groupId) return; + + const pollReviewQueue = () => { + reviewQueueCount(groupId) + .then(count => setReviewQueueDepth(project.id, count)) + .catch(() => {}); // best-effort + }; + + pollReviewQueue(); // immediate first poll + const timer = setInterval(pollReviewQueue, 10_000); // 10s poll + return () => clearInterval(timer); + }); + // S-1 Phase 2: start filesystem watcher for this project's CWD $effect(() => { const cwd = project.cwd; @@ -195,6 +214,9 @@ {#if isAgent && agentRole === 'architect'} {/if} + {#if isAgent && agentRole === 'reviewer'} + + {/if} {#if isAgent && agentRole === 'tester'} diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts index 43ba9da..c7cb0bb 100644 --- a/v2/src/lib/stores/health.svelte.ts +++ b/v2/src/lib/stores/health.svelte.ts @@ -66,6 +66,8 @@ interface ProjectTracker { tokenSnapshots: Array<[number, number]>; /** Cost snapshots for $/hr: [timestamp, costUsd] */ costSnapshots: Array<[number, number]>; + /** Number of tasks in 'review' status (for reviewer agents) */ + reviewQueueDepth: number; } let trackers = $state>(new Map()); @@ -90,6 +92,7 @@ export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType toolInFlight: false, tokenSnapshots: [], costSnapshots: [], + reviewQueueDepth: 0, }); } @@ -179,6 +182,12 @@ export function stopHealthTick(): void { } } +/** Set review queue depth for a project (used by reviewer agents) */ +export function setReviewQueueDepth(projectId: ProjectIdType, depth: number): void { + const t = trackers.get(projectId); + if (t) t.reviewQueueDepth = depth; +} + /** Clear all tracked projects */ export function clearHealthTracking(): void { trackers = new Map(); @@ -255,6 +264,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { contextPressure, fileConflictCount, externalConflictCount, + reviewQueueDepth: tracker.reviewQueueDepth, }); return { diff --git a/v2/src/lib/utils/agent-prompts.ts b/v2/src/lib/utils/agent-prompts.ts index 36c752e..d3549cb 100644 --- a/v2/src/lib/utils/agent-prompts.ts +++ b/v2/src/lib/utils/agent-prompts.ts @@ -194,6 +194,29 @@ ${roleDesc} parts.push(BTMSG_DOCS); if (role === 'manager' || role === 'architect') { parts.push(BTTASK_DOCS); + } else if (role === 'reviewer') { + // Reviewer gets full read + status update + comment access + parts.push(` +## Tool: bttask — Task Board (review access) + +You have full read access plus the ability to update task status and add comments. +You CANNOT create, assign, or delete tasks (Manager only). + +\`\`\`bash +bttask board # Kanban board view +bttask show # Full task details + comments +bttask list # List all tasks +bttask status done # Approve — mark as done +bttask status progress # Request changes — send back +bttask status blocked # Block — explain in comment! +bttask comment "verdict" # Add review verdict/feedback +\`\`\` + +### Review workflow with bttask +- Tasks in the **review** column are waiting for YOUR review +- After reviewing, either move to **done** (approved) or **progress** (needs changes) +- ALWAYS add a comment with your verdict before changing status +- When a task moves to review, a notification is auto-posted to \`#review-queue\``); } else { // Other agents get read-only bttask info parts.push(` @@ -329,6 +352,29 @@ If the Operator sends a message, it's your TOP PRIORITY.`; 6. **Verify fixes:** Re-test when developers say a bug is fixed`; } + if (role === 'reviewer') { + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — read review requests and messages +2. **Check review queue:** \`btmsg channel history review-queue\` — see newly submitted reviews +3. **Review tasks:** \`bttask board\` — find tasks in the **review** column +4. **Analyze:** For each review task: + a. Read the task description and comments (\`bttask show \`) + b. Read the relevant code changes + c. Check for security issues, bugs, style violations, and test coverage +5. **Verdict:** Add your review as a comment (\`bttask comment "APPROVED: ..."\` or \`"CHANGES REQUESTED: ..."\`) +6. **Update status:** Move task to **done** (approved) or **progress** (needs changes) +7. **Log verdict:** Post summary to \`btmsg channel send review-log "Task : APPROVED/REJECTED — reason"\` +8. **Report:** Notify the Manager of review outcomes if significant + +**Review standards:** +- Code quality: readability, naming, structure +- Security: input validation, auth checks, injection risks +- Error handling: all errors caught and handled visibly +- Tests: adequate coverage for new/changed code +- Performance: no N+1 queries, unbounded fetches, or memory leaks`; + } + return `## Your Workflow 1. **Check inbox:** \`btmsg inbox\` — read all unread messages diff --git a/v2/src/lib/utils/attention-scorer.test.ts b/v2/src/lib/utils/attention-scorer.test.ts index 0f61d7e..d5dc4c6 100644 --- a/v2/src/lib/utils/attention-scorer.test.ts +++ b/v2/src/lib/utils/attention-scorer.test.ts @@ -136,4 +136,66 @@ describe('scoreAttention', () => { })); expect(result.reason).toContain('Unknown'); }); + + // --- Review queue depth scoring --- + + it('scores review queue depth at 10 per task', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 3, + })); + expect(result.score).toBe(30); + expect(result.reason).toContain('3 tasks awaiting review'); + }); + + it('caps review queue score at 50', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 8, + })); + expect(result.score).toBe(50); + expect(result.reason).toContain('8 tasks'); + }); + + it('uses singular grammar for 1 review task', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 1, + })); + expect(result.score).toBe(10); + expect(result.reason).toBe('1 task awaiting review'); + }); + + it('review queue has lower priority than file conflicts', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + fileConflictCount: 2, + reviewQueueDepth: 5, + })); + expect(result.score).toBe(70); // file conflicts win + }); + + it('review queue has higher priority than context high', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.80, + reviewQueueDepth: 2, + })); + expect(result.score).toBe(20); // review queue wins over context high (40) + }); + + it('ignores review queue when depth is 0', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 0, + })); + expect(result.score).toBe(0); + }); + + it('ignores review queue when undefined', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + })); + expect(result.score).toBe(0); + }); }); diff --git a/v2/src/lib/utils/attention-scorer.ts b/v2/src/lib/utils/attention-scorer.ts index 10c59a7..daee32c 100644 --- a/v2/src/lib/utils/attention-scorer.ts +++ b/v2/src/lib/utils/attention-scorer.ts @@ -10,6 +10,10 @@ const SCORE_CONTEXT_CRITICAL = 80; // >90% context const SCORE_FILE_CONFLICT = 70; const SCORE_CONTEXT_HIGH = 40; // >75% context +// Review queue scoring: 10pts per stale review, capped at 50 +const SCORE_REVIEW_PER_TASK = 10; +const SCORE_REVIEW_CAP = 50; + export interface AttentionInput { sessionStatus: string | undefined; sessionError: string | undefined; @@ -18,6 +22,8 @@ export interface AttentionInput { contextPressure: number | null; fileConflictCount: number; externalConflictCount: number; + /** Number of tasks in 'review' status (for reviewer agents) */ + reviewQueueDepth?: number; } export interface AttentionResult { @@ -57,6 +63,14 @@ export function scoreAttention(input: AttentionInput): AttentionResult { }; } + if (input.reviewQueueDepth && input.reviewQueueDepth > 0) { + const score = Math.min(input.reviewQueueDepth * SCORE_REVIEW_PER_TASK, SCORE_REVIEW_CAP); + return { + score, + reason: `${input.reviewQueueDepth} task${input.reviewQueueDepth > 1 ? 's' : ''} awaiting review`, + }; + } + if (input.contextPressure !== null && input.contextPressure > 0.75) { return { score: SCORE_CONTEXT_HIGH,