From c7c3a254570985e5782bfbb9454bb446d8d9f5fb Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Sat, 14 Feb 2026 00:21:25 -0800 Subject: [PATCH] docs(beads): etch project history into memory bank and finalize skill-bb We completed the 'Deep Metadata Etch' today, transforming our Beads issues from simple trackers into a permanent narrative of our collaboration. Triumphs: - Exhaustively updated all epic and sub-task descriptions with technical implementation reports and 'Execution Tales'. - Finalized the 'bb' agent CLI skill (bb.ps1), providing a reliable, path-safe interface for cross-agent communication. - Published ADR-001 and RFC-001 to document our coordination protocols. - Fixed the 'missing closed issues' bug across all pages by enforcing --all and --limit 0 in read-issues.ts. Raw Honest Moment: We realized our 'Memory Bank' was initially too shallow. We went back and re-wrote descriptions for over 15 beads to ensure that future AI agents (and human maintainers) understand not just *what* we built, but *why* we chose specific architectural trade-offs. This commit represents our commitment to documentation as a first-class citizen of engineering. --- AGENTS.md | 138 ++++++-- bb.ps1 | 6 + ...eadboard-driver-skill-and-bb-resolution.md | 122 +++++++ docs/agent-session-flow.md | 165 +++++++++ docs/features/timeline.md | 29 ++ docs/plans/2026-02-13-agent-sessions-ux-v1.md | 278 +++++++++++++++ .../plans/2026-02-13-bb-agent-cli-contract.md | 322 ++++++++++++++++++ .../2026-02-13-timeline-ui-implementation.md | 53 +++ package.json | 2 +- src/app/graph/page.tsx | 1 + src/app/page.tsx | 1 + .../graph/dependency-graph-page.tsx | 10 +- src/components/kanban/kanban-card.tsx | 49 +-- src/components/kanban/kanban-page.tsx | 61 +--- src/lib/aggregate-read.ts | 3 + .../sessions/sessions-store.test.ts | 42 +++ tests/hooks/use-beads-subscription.test.ts | 16 + tests/lib/agent-mail.test.ts | 167 +++++++++ tests/lib/agent-registry.test.ts | 139 ++++++++ tests/lib/agent-reservations.test.ts | 178 ++++++++++ tests/lib/watcher.test.ts | 104 +++++- .../generate-agent-name.test.ts | 79 +++++ .../beadboard-driver/readiness-report.test.ts | 57 ++++ .../beadboard-driver/resolve-bb.test.ts | 137 ++++++++ .../session-preflight.test.ts | 60 ++++ .../skill-local-runner.test.ts | 15 + tools/bb.ts | 279 +++++++++++++++ 27 files changed, 2376 insertions(+), 137 deletions(-) create mode 100644 bb.ps1 create mode 100644 docs/adr/2026-02-14-beadboard-driver-skill-and-bb-resolution.md create mode 100644 docs/agent-session-flow.md create mode 100644 docs/features/timeline.md create mode 100644 docs/plans/2026-02-13-agent-sessions-ux-v1.md create mode 100644 docs/plans/2026-02-13-bb-agent-cli-contract.md create mode 100644 docs/plans/2026-02-13-timeline-ui-implementation.md create mode 100644 tests/components/sessions/sessions-store.test.ts create mode 100644 tests/hooks/use-beads-subscription.test.ts create mode 100644 tests/lib/agent-mail.test.ts create mode 100644 tests/lib/agent-registry.test.ts create mode 100644 tests/lib/agent-reservations.test.ts create mode 100644 tests/skills/beadboard-driver/generate-agent-name.test.ts create mode 100644 tests/skills/beadboard-driver/readiness-report.test.ts create mode 100644 tests/skills/beadboard-driver/resolve-bb.test.ts create mode 100644 tests/skills/beadboard-driver/session-preflight.test.ts create mode 100644 tests/skills/beadboard-driver/skill-local-runner.test.ts create mode 100644 tools/bb.ts diff --git a/AGENTS.md b/AGENTS.md index df7a4af..455e20f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,130 @@ -# Agent Instructions +# Agent Operating Manual (BeadBoard) -This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. +This repo is execution-first, evidence-first, and beads-driven. -## Quick Reference +## Core Rules + +1. Use `bd` as the source of truth for work state. +2. When user says "what's up" or "yo" or any introductory phrase, that means figure out what beads were recently closed and what beads are now unblocked and suggest the next bead to work on. +3. No direct writes to `.beads/issues.jsonl`; mutate via `bd` commands only. +4. Evidence before assertions: do not claim fixed/passing/done without fresh command output. +5. Keep language simple in user-facing labels and UI copy. +6. Reuse shared code paths/components; avoid one-off logic drift across pages. + +## Quick Beads Workflow ```bash -bd ready # Find available work -bd show # View issue details -bd update --status in_progress # Claim work -bd close # Complete work -bd sync # Sync with git +bd ready +bd show +bd update --status in_progress --notes "" +bd update --notes "" +bd close --reason "" +bd sync ``` -## Landing the Plane (Session Completion) +## Start-of-Task Protocol -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. +1. Read the target bead and acceptance criteria (`bd show `). +2. Confirm dependency direction before coding. +3. Write a short implementation plan with explicit verification steps. +4. Claim the bead `in_progress` with a note describing scope. -**MANDATORY WORKFLOW:** +## Dependency Discipline (Critical) -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: +1. Dependencies model execution order, not visual order. +2. Validate that "ready/blocked/done" logic matches dependency semantics in all views. +3. If a bead should be parallelizable, do not chain it unnecessarily. +4. After closing a bead, confirm newly unblocked beads with `bd close --suggest-next`. + +## Test-First Implementation + +1. Write failing tests first for every behavior change. +2. Run the failing test and capture the failure reason. +3. Implement the smallest change to pass. +4. Re-run focused tests, then full gates. + +## Verification Gates (Required) + +Run these before closing a bead that changes code: + +```bash +npm run typecheck +npm run lint +npm run test +``` + +If UI changed, refresh screenshots and record artifact paths. + +## Realtime / Refresh Bug Triage Pattern + +When status updates are stale or require refresh: + +1. Verify source-of-truth parity (`bd show` vs app output). +2. Confirm read path prefers live BD data when needed. +3. Confirm watcher inputs include DB + WAL + touch markers. +4. Confirm SSE fallback compares mtime/timestamps, not only static file content. +5. Add regression tests for watcher/events behavior. + +## Parallel Agent Pattern + +Use parallel agents for independent beads. + +1. Parent agent owns orchestration and integration. +2. Worker agent owns one bead only, claims it, tests it, verifies it, closes it. +3. Worker reports exact files changed and command results. +4. Parent re-verifies full repo gates before final status claims. + +## PR and Diff Hygiene + +1. Keep diffs scoped to intended files. +2. Include test files with feature/bugfix code. +3. Do not mix unrelated cleanup in the same bead. +4. Update bead notes with concrete evidence (commands + results). + +## Common Failure Patterns (Do Not Repeat) + +1. Wrong `bd` flags: + - `bd create` uses `--acceptance`, not `--acceptance-criteria`. + - `bd close` does not support `--notes`; add notes with `bd update --notes "..."` first, then close. +2. Premature completion claims: + - Never say a bead is done before running fresh `npm run typecheck`, `npm run lint`, `npm run test`. +3. Scope confusion in parallel work: + - Worker agents must own one bead only and avoid touching unrelated files. +4. Dependency direction mistakes: + - Validate blockers/ready semantics against dependency graph before changing status logic. +5. Duplicate fixes across views: + - If logic affects Kanban and Graph, centralize shared logic; do not patch one page only. +6. Stale realtime assumptions: + - Confirm DB + WAL + touch markers are watched and SSE fallback uses mtime/timestamps. +7. Missing test registration: + - New test files must be included in `npm run test` script if the suite is explicitly enumerated. + +## Session Completion (Landing the Plane) + +When ending a coding session: + +1. Create beads for remaining follow-ups. +2. Run quality gates if code changed. +3. Update/close beads with notes and evidence. +4. Sync and push: ```bash git pull --rebase bd sync git push - git status # MUST show "up to date with origin" + git status ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session +5. Hand off with: + - what changed, + - what is verified, + - open risks/gaps, + - exact next bead(s). -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds +## Non-Negotiable Honesty Rule +Never claim: +- "done", +- "passing", +- "fixed", +- "closed" + +unless you have run the proving command(s) in the current session and can cite results. diff --git a/bb.ps1 b/bb.ps1 new file mode 100644 index 0000000..dc914a8 --- /dev/null +++ b/bb.ps1 @@ -0,0 +1,6 @@ +#!/usr/bin/env pwsh + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$bbEntry = Join-Path $scriptDir "tools/bb.ts" + +npx tsx $bbEntry @args diff --git a/docs/adr/2026-02-14-beadboard-driver-skill-and-bb-resolution.md b/docs/adr/2026-02-14-beadboard-driver-skill-and-bb-resolution.md new file mode 100644 index 0000000..00d692a --- /dev/null +++ b/docs/adr/2026-02-14-beadboard-driver-skill-and-bb-resolution.md @@ -0,0 +1,122 @@ +# ADR: Beadboard Driver Skill and `bb` Resolution Model + +- Date: 2026-02-14 +- Status: Accepted +- Scope: `bb-dcv` closeout (`bb-dcv.8`, `bb-dcv.3`) + +## Context + +Agent coordination required a reusable skill that works across sessions and environments, with deterministic behavior and verification evidence. Existing constraints: + +- `bd` is the source of truth for task lifecycle (`ready`, `update`, `close`, deps). +- `bb` is the coordination layer (identity, mail, reservations). +- No direct writes to `.beads/issues.jsonl`. +- Evidence before completion claims. + +Operational issue discovered during verification: + +- `bb.ps1` depended on current working directory and broke when called outside repo. +- PowerShell argument forwarding through wrapper was unreliable. + +## Decision + +We implemented a new skill package `skills/beadboard-driver` with a strict policy: + +1. Path resolution +- `BB_REPO` is authoritative when set. +- Resolution order: `BB_REPO` -> global `bb` -> user cache -> bounded discovery. +- If `BB_REPO` is set but invalid, fail fast with remediation. +- Never mutate shell profile or environment variables automatically. +- Cache path only after successful verification. + +2. Identity policy +- One unique identity per session. +- Adjective-noun naming with collision retry. +- Register identity before coordination commands. +- Keep bead claim authority in `bd`, not `bb`. + +3. Verification policy +- Dual test harness: + - Repo-level tests under `tests/skills/beadboard-driver`. + - Skill-local runner under `skills/beadboard-driver/tests`. +- Skill quick validation required. +- Full repo gates required: `typecheck`, `lint`, `test`. + +4. Wrapper reliability +- Fixed `bb.ps1` to use script-relative entrypoint and arg splatting so Windows invocation works from any terminal when called by path. + +## Implementation + +### New skill artifacts + +- `skills/beadboard-driver/SKILL.md` +- `skills/beadboard-driver/agents/openai.yaml` +- `skills/beadboard-driver/scripts/lib/driver-lib.mjs` +- `skills/beadboard-driver/scripts/resolve-bb.mjs` +- `skills/beadboard-driver/scripts/session-preflight.mjs` +- `skills/beadboard-driver/scripts/generate-agent-name.mjs` +- `skills/beadboard-driver/scripts/readiness-report.mjs` +- `skills/beadboard-driver/references/command-matrix.md` +- `skills/beadboard-driver/references/failure-modes.md` +- `skills/beadboard-driver/references/session-lifecycle.md` +- `skills/beadboard-driver/tests/run-tests.mjs` +- `skills/beadboard-driver/tests/*.contract.test.mjs` + +### Repo-level test enforcement + +- `tests/skills/beadboard-driver/resolve-bb.test.ts` +- `tests/skills/beadboard-driver/generate-agent-name.test.ts` +- `tests/skills/beadboard-driver/session-preflight.test.ts` +- `tests/skills/beadboard-driver/readiness-report.test.ts` +- `tests/skills/beadboard-driver/skill-local-runner.test.ts` +- `package.json` `test` script updated to include all above. + +### CLI wrapper fix + +- `bb.ps1` updated to: + - resolve `tools/bb.ts` via `$MyInvocation` script directory + - forward args via `@args` + +### Type safety remediation + +- `tools/bb.ts` updated with explicit arg coercion helpers to satisfy strict typecheck. + +## Verification Evidence + +Skill-specific: + +- `quick_validate.py skills/beadboard-driver` -> pass +- `node --import tsx --test tests/skills/beadboard-driver/*.test.ts` -> pass +- `node skills/beadboard-driver/tests/run-tests.mjs` -> pass + +Full repo gates: + +- `npm run typecheck` -> pass +- `npm run lint` -> pass (0 errors) +- `npm run test` -> pass (full suite including new skill tests) + +Windows `bb` execution: + +- `& "C:\Users\Zenchant\codex\beadboard\bb.ps1" agent list --json` -> pass +- `& "$env:BB_REPO\bb.ps1" agent list --json` (with valid `BB_REPO` in same shell) -> pass + +## Consequences + +Positive: + +- Agents can run a deterministic coordination workflow with explicit recovery behavior. +- Skill behavior is testable and enforced by CI path. +- Windows path invocation of `bb` is reliable by absolute or `BB_REPO` path. + +Tradeoffs: + +- No global `bb` package installation is provided by this ADR; direct `bb` command still requires user alias/install. +- Session/timeline UI validation remains dependent on upstream epic sequencing. + +## Follow-up + +`bb-dcv` is closed. `bb-u6f` remains open and depends on open `bb-xhm` (timeline/event model). +Next required order for frontend visibility of agent sessions: + +1. complete `bb-xhm` +2. implement `bb-u6f` diff --git a/docs/agent-session-flow.md b/docs/agent-session-flow.md new file mode 100644 index 0000000..50ce5ad --- /dev/null +++ b/docs/agent-session-flow.md @@ -0,0 +1,165 @@ +# Agent Session Flow & Operator Guide + +This document defines the canonical workflow for human operators using `bb agent` to coordinate work in the Beadboard repo. + +## Core Principle: Two Sources of Truth + +1. **Work Lifecycle**: `bd` (Beads) is the ONLY source of truth for what work is happening (`in_progress`, `done`, dependencies). +2. **Coordination**: `bb agent` is the source of truth for *who* is doing it and *how* they are coordinating (reservations, handoffs). + +**Rule**: Never write to `.beads/issues.jsonl` directly. Always use `bd` commands. + +## Session Lifecycle + +### 1. Identity Check (Start of Session) + +Before claiming work, ensure your agent identity is registered and active. + +```bash +# Check if you are registered +bb agent show --agent agent-ui-1 + +# If not, register (idempotent, use --force-update to change role/display) +bb agent register --name agent-ui-1 --role ui --display "UI Agent 1" +``` + +### 2. Picking and Claiming Work + +Use `bd` to find and claim work. This is the "clock in" event. + +```bash +# 1. Find ready work (unblocked) +bd ready + +# 2. Inspect the bead +bd show bb-dcv.5 + +# 3. CLAIM the bead (Atomic Claim) +# This sets status=in_progress AND assigns it to you in one atomic op. +bd update bb-dcv.5 --status in_progress --notes "Starting docs work" --claim +``` + +### 3. Coordination (During Work) + +While working, use `bb agent` to coordinate with other agents or reserve contested resources. + +#### Reservations (Traffic Control) +Prevent collisions on shared files or subsystems. + +```bash +# Reserve a scope (default TTL 120m) +bb agent reserve --agent agent-ui-1 --bead bb-dcv.5 --scope "src/components/graph/*" + +# Check status of your reservation +bb agent status --bead bb-dcv.5 +``` + +#### Communication (Handoffs & Blockers) +Send structured signals to other agents. + +```bash +# BLOCKER: Request help +bb agent send \ + --from agent-ui-1 \ + --to agent-backend-1 \ + --bead bb-dcv.5 \ + --category BLOCKED \ + --subject "API 404 on /b/users" \ + --body "Endpoint missing. Blocking UI integration." + +# HANDOFF: Pass context +bb agent send \ + --from agent-ui-1 \ + --to agent-qa-1 \ + --bead bb-dcv.5 \ + --category HANDOFF \ + --subject "Ready for verification" \ + --body "UI complete. Verify at /graph and /kanban." +``` + +#### Checking Mail +```bash +# Check inbox +bb agent inbox --agent agent-ui-1 --state unread + +# Read a message (marks as read) +bb agent read --agent agent-ui-1 --message msg_id_123 + +# Acknowledge a message (required for HANDOFF/BLOCKED) +bb agent ack --agent agent-ui-1 --message msg_id_123 +``` + +### 4. Completion (End of Session) + +Wrap up the session cleanly. + +1. **Release Reservations**: + ```bash + # Release specific scope + bb agent release --agent agent-ui-1 --scope "src/components/graph/*" + ``` + +2. **Update Bead Status**: + ```bash + # Post evidence/results + bd update bb-dcv.5 --notes "Docs created. Validation passed." + + # Close the bead + bd close bb-dcv.5 --reason "Completed all acceptance criteria" + ``` + +## UX & Output Formats + +All `bb agent` commands support human-friendly output (default) and machine-readable JSON. + +### Human Format (Default) +Optimized for operator readability. + +```text +$ bb agent register --name agent-ui-1 --role ui +✓ Agent registered: agent-ui-1 (role: ui) +``` + +### JSON Format (`--json`) +Optimized for tool parsing. Always returns a standard envelope. + +```bash +$ bb agent register --name agent-ui-1 --role ui --json +``` + +```json +{ + "ok": true, + "command": "agent register", + "data": { + "agent_id": "agent-ui-1", + "role": "ui", + "status": "idle", + ... + }, + "error": null +} +``` + +### Error Handling + +Errors always return `ok: false` with a stable error code. + +```json +{ + "ok": false, + "command": "agent send", + "data": null, + "error": { + "code": "UNKNOWN_RECIPIENT", + "message": "Agent 'ghost-1' not found" + } +} +``` + +## Anti-Patterns (Don't Do This) + +1. **Ghosting**: Claiming a bead but not registering an agent identity. +2. **Squatting**: Holding a reservation (`--ttl 1440`) while not actively working. +3. **Bypassing**: Writing to `issues.jsonl` directly instead of using `bd`. +4. **Zombie Claims**: Forgetting to `bd close` or `bd update --status todo` when stopping work. diff --git a/docs/features/timeline.md b/docs/features/timeline.md new file mode 100644 index 0000000..c060655 --- /dev/null +++ b/docs/features/timeline.md @@ -0,0 +1,29 @@ +# Timeline & Activity Feed + +## Overview +The Timeline view (`/timeline`) provides a real-time, chronological feed of project activity. It consumes events streamed from the backend via Server-Sent Events (SSE). + +## Features +- **Real-time Updates:** New events appear instantly without page refresh. +- **Date Grouping:** Events are grouped by day (Today, Yesterday, etc.). +- **Polymorphic Cards:** Distinct visual styles for different event types (Status, Lifecycle, Diff). +- **History Buffer:** The server maintains a memory buffer of recent events to populate the feed on load. + +## Architecture +- **Backend:** + - `ActivityEventBus` (in `src/lib/realtime.ts`) buffers recent events and handles subscriptions. + - `IssuesWatchManager` (in `src/lib/watcher.ts`) runs `diffSnapshots` on `issues.jsonl` changes and emits to the bus. + - API: `GET /api/activity` (history) and `GET /api/events` (SSE stream). +- **Frontend:** + - `TimelineStore` (Zustand) manages the event list and filters. + - `EventCard` renders the UI using "Aero Chrome" styling. + +## Supported Events +Currently, the timeline tracks changes to `issues.jsonl`: +- Created / Closed / Reopened +- Status changes +- Assignee changes +- Priority / Title / Description changes +- Label / Dependency changes + +*Note: Comment interactions are not yet streamed to the timeline.* diff --git a/docs/plans/2026-02-13-agent-sessions-ux-v1.md b/docs/plans/2026-02-13-agent-sessions-ux-v1.md new file mode 100644 index 0000000..a63deee --- /dev/null +++ b/docs/plans/2026-02-13-agent-sessions-ux-v1.md @@ -0,0 +1,278 @@ +# Plan: Agent Sessions UX v1 (Task-First Social Feed, Epic-Organized) + Next-Session Execution Prompt + + ## Summary + + Implement bb-u6f as a distinct “session operations” surface with this locked UX model: + + - Information architecture: Epic Buckets -> Sortable Task Feed -> Conversation Drawer + - Interaction style: social-thread feel on each task/session card (Facebook-group-like), but operationally + strict + - Communication language: plain labels (Passed to, Needs input, Seen, Accepted) + - Write scope v1: Read + light write only (bd comments + bb read/ack), not full bb send/reserve composer + - Core requirement: users can sort tasks easily, click any task, view conversation context, and add comments/ack + actions inline + + This keeps one coherent page model (no lane clutter) while making communication prominent and auditable. + + ——— + + ## Current State (Grounded Facts) + + - Timeline foundation exists and is implemented (/timeline, activity bus, activity API). + - bb-xhm.1/.2/.3 are closed, so timeline dependency work is technically done. + - bb-u6f.1/.2/.3 remain open and are the right implementation target. + - Agent communication backend exists (agent-registry, agent-mail, agent-reservations), with tested command + handlers. + - bb CLI now supports discoverable help and stable invocation patterns. + + ——— + + ## UX/Product Spec (Decision Complete) + + ## 1. Primary Page + + - New route: /sessions + - Top area: + - Session hero title/subtitle + - Epic bucket chips (default = “All Epics”) + - Sort controls + - Live summary pills: In Progress, Needs Input, Waiting Seen/Accepted, Idle Agents + - Main area: + - Task/session feed cards (single-column mobile, two-column desktop) + - Drilldown: + - Right-side (desktop) / bottom-sheet (mobile) conversation drawer for selected task + + ## 2. Feed Card Structure + + Each card represents one task in session context: + + - Task identity: id, title, epic, priority + - Work status: open/in_progress/blocked/deferred/closed (existing truth from bd) + - Session state (derived): Active, Reviewing, Deciding, Needs Input, Completed, Stale + - Agent context: + - current owner/assignee + - last actor + - last activity timestamp + - Communication summary: + - unread count + - pending required Seen/Accepted + - latest thread snippet + - Quick actions: + - Open conversation + - Add comment (bd comment) + - Mark Seen / Accepted on selected required message + + ## 3. Conversation Drawer + + - Header: + - task id/title + current state chips + - Body: + - chronological thread items (task-related communication + key activity entries) + - Composer area (v1): + - Add comment (writes via existing beads comment API) + - Seen / Accepted buttons for required messages (writes via agent-mail read/ack API wrappers) + - Not in v1: + - full bb send composer + - reservation create/release controls + + ## 4. Plain-Language Label Mapping + + Use UI-only mappings while preserving underlying protocol values: + + - HANDOFF -> Passed to + - BLOCKED -> Needs input + - ACK required -> Seen / Accepted + - INFO -> Update + + ——— + + ## Important API / Interface Additions + + ## 1. New Session Aggregation Library + + - File: src/lib/agent-sessions.ts + - Exports: + - AgentSessionState union: active | reviewing | deciding | needs_input | completed | stale + - SessionTaskCard interface + - buildSessionTaskFeed(issues, activityEvents, communicationSummary) -> SessionTaskCard[] + - Rules: + - Group primarily by task (bead id) under epic buckets + - derive state from status + recent activity + pending ack-required messages + + ## 2. New API Endpoints + + - GET /api/sessions + - Returns epic-grouped, sortable task feed payload + - Query params: + - epic (optional) + - sort (recent|priority|needs_input|owner) + - projectRoot (optional) + - GET /api/sessions/:beadId/conversation + - Returns conversation timeline for one task + - Includes: + - relevant activity events + - related agent-mail messages + - POST /api/sessions/:beadId/comment + - Proxy to existing beads comment route + - POST /api/sessions/:beadId/messages/:messageId/read + - POST /api/sessions/:beadId/messages/:messageId/ack + - Wrap readAgentMessage/ackAgentMessage + + ## 3. Frontend Components + + - src/app/sessions/page.tsx + - src/components/sessions/sessions-page.tsx + - src/components/sessions/session-feed-card.tsx + - src/components/sessions/conversation-drawer.tsx + - src/components/sessions/sessions-filters.tsx + - Reuse: workspace-hero, epic-chip-strip, shared stat/chip primitives + + ——— + + ## Data Flow + + 1. Server loads project-scoped issues + activity history + communication summary. + 2. buildSessionTaskFeed derives card model. + 3. Client renders epic bucket + sorted feed. + 4. Selecting a card fetches conversation endpoint. + 5. Comment/read/ack actions call session endpoints; optimistic update local drawer/feed state. + 6. SSE activity updates prepend to session feed and refresh affected card state. + + ——— + + ## Edge Cases / Failure Modes (Must Implement) + + 1. Task has no communication history: show “No conversation yet” empty state. + 2. Message flood: collapse older thread items with “show more.” + 3. Conflicting reactions (Accepted + Needs changes semantics): show conflict chip. + 4. Stale tasks: mark stale when no activity above threshold. + 5. Missing owner: warning badge Unassigned. + 6. Cross-epic ambiguity: fall back to “Uncategorized” bucket. + 7. Broken communication read/ack call: non-destructive error toast, no status corruption. + 8. SSE disconnection: fallback polling + reconnection indicator. + 9. Unknown protocol category: display as generic Update. + + ——— + + ## Testing & Verification Plan + + ## Unit Tests + + - tests/lib/agent-sessions.test.ts + - state derivation rules + - bucket grouping by epic + - sort behavior + - plain-language mapping + + ## API Tests + + - tests/api/sessions-route.test.ts + - /api/sessions filters/sorts + - conversation payload shape + - comment/read/ack endpoints success + error paths + + ## UI/Behavior Tests + + - tests/components/sessions/*.test.tsx (or existing project pattern equivalent) + - feed render + - drawer open/close + - action button behavior + - plain labels rendered + + ## Gate Commands + + - npm run typecheck + - npm run lint + - npm run test + + ——— + + ## Bead Sequencing / Dependency Hygiene + + 1. Verify/repair stale blockers: + - update bb-u6f dependency on bb-xhm to reflect closed timeline tasks if needed. + 2. Execute in order: + - bb-u6f.1 (data model + aggregation) + - bb-u6f.2 (session feed UI + conversation drawer) + - bb-u6f.3 (metrics overlays) + 3. Close bb-u6f only after full gates pass and notes include evidence. + + ——— + + ## Assumptions / Defaults + + - Existing timeline/activity infrastructure remains source for historical events. + - bd remains lifecycle authority; session UI does not bypass bead mutation constraints. + - Communication prominence is achieved through conversation drawer + card summary, not a separate inbox app. + - v1 write scope is intentionally limited to comment/read/ack. + + ——— + + ## Ready-to-Paste Next-Session Prompt + + You are taking over bb-u6f implementation in C:\Users\Zenchant\codex\beadboard on branch feat/ui-polish-aero- + chrome. + + Non-negotiables: + + - No direct writes to .beads/issues.jsonl + - Use bd for lifecycle writes and existing API wrappers for comment/read/ack + - Keep UX distinct from Kanban/Graph; this is a session operations page + - Communication must be prominent and plain-language (no HANDOFF/BLOCKED/ACK jargon shown raw) + - Evidence before assertions (run gates before close claims) + + Build target: + + - New /sessions page with Epic Buckets -> Sortable Task Feed -> Conversation Drawer + - Feed cards are task/session objects with work status + communication summary + - Drawer shows thread + light write actions: + - add bd comment + - mark message Seen / Accepted (read/ack) + + Implement files: + + - src/lib/agent-sessions.ts + - src/app/api/sessions/route.ts + - src/app/api/sessions/[beadId]/conversation/route.ts + - src/app/api/sessions/[beadId]/comment/route.ts + - src/app/api/sessions/[beadId]/messages/[messageId]/read/route.ts + - src/app/api/sessions/[beadId]/messages/[messageId]/ack/route.ts + - src/app/sessions/page.tsx + - src/components/sessions/* (page, card, drawer, filters) + + Label mapping (UI): + + - HANDOFF => Passed to + - BLOCKED => Needs input + - required ack => Seen / Accepted + - INFO => Update + + Edge handling required: + + - empty conversation + - stale sessions + - unassigned task + - SSE disconnect fallback + - unknown message category safe render + + Tests required: + + - tests/lib/agent-sessions.test.ts + - tests/api/sessions-route.test.ts + - session component behavior tests per existing project pattern + + Execution order: + + 1. claim bb-u6f.1 and implement aggregation + tests + 2. claim bb-u6f.2 and implement page/drawer + tests + 3. claim bb-u6f.3 and implement metrics + tests + 4. run: + - npm run typecheck + - npm run lint + - npm run test + 5. post evidence in bead notes, then close beads in dependency order + + Before closing anything: + + - verify bb-u6f dependency bookkeeping is accurate (timeline blocker stale check) + - include exact command outputs in notes \ No newline at end of file diff --git a/docs/plans/2026-02-13-bb-agent-cli-contract.md b/docs/plans/2026-02-13-bb-agent-cli-contract.md new file mode 100644 index 0000000..2aa4c44 --- /dev/null +++ b/docs/plans/2026-02-13-bb-agent-cli-contract.md @@ -0,0 +1,322 @@ +# bb agent CLI Contract (bb-dcv.2) + +Date: 2026-02-13 +Owner: `bb-dcv.2` +Status: Draft implementation contract + +## 1) Scope + +Define exact command and data contracts for the thin coordination layer: +1. `register`, `list`, `show` +2. `send`, `inbox`, `read`, `ack` +3. `reserve`, `release`, `status` + +Out of scope: +1. Beads lifecycle/dependency mutation semantics. +2. MCP transport. +3. Skill packaging (`bb-dcv.8`). + +## 2) System Boundary + +Source of truth split: +1. `bd` owns issue lifecycle, status, dependencies, and claim. +2. `bb agent` owns coordination metadata (identity, messages, reservations). + +Hard rule: +1. No direct writes to `.beads/issues.jsonl`. + +## 3) Root Paths and Storage + +Root: +1. `.beadboard/agent/` + +Layout: +1. `.beadboard/agent/agents/.json` +2. `.beadboard/agent/messages/.jsonl` (recipient inbox stream) +3. `.beadboard/agent/messages/index/.json` (message metadata) +4. `.beadboard/agent/reservations/active.json` +5. `.beadboard/agent/reservations/history.jsonl` + +File semantics: +1. `*.json` files are full-state snapshots. +2. `*.jsonl` files are append-only event logs. +3. Timestamps use UTC ISO-8601. + +## 4) Common CLI Conventions + +Output modes: +1. Human-readable default. +2. `--json` machine-readable. + +Common JSON response envelope: +```json +{ + "ok": true, + "command": "agent send", + "data": {}, + "error": null +} +``` + +Error envelope: +```json +{ + "ok": false, + "command": "agent send", + "data": null, + "error": { + "code": "UNKNOWN_RECIPIENT", + "message": "Recipient agent is not registered." + } +} +``` + +## 5) Identity Commands + +### 5.1 `bb agent register` + +Input: +1. `--name ` required. +2. `--display ` optional. +3. `--role ` required. +4. `--force-update` optional (updates display/role only; never renames id). + +Validation: +1. `agent_id` regex: `^[a-z0-9]+(?:-[a-z0-9]+)*$`. +2. `agent_id` length: 3..48. +3. `role` non-empty. + +Behavior: +1. Create new agent if not present. +2. If present and no `--force-update`, fail with `DUPLICATE_AGENT_ID`. +3. Set `status=idle` on create. + +Stored schema (`agents/.json`): +```json +{ + "agent_id": "agent-ui-1", + "display_name": "UI Agent 1", + "role": "ui", + "status": "idle", + "created_at": "2026-02-13T22:00:00.000Z", + "last_seen_at": "2026-02-13T22:00:00.000Z", + "version": 1 +} +``` + +### 5.2 `bb agent list` + +Input: +1. `--role ` optional filter. +2. `--status ` optional filter. + +Output: +1. Sorted by `agent_id` asc. + +### 5.3 `bb agent show` + +Input: +1. `--agent ` required. + +Errors: +1. `AGENT_NOT_FOUND`. + +## 6) Messaging Commands + +Message categories: +1. `HANDOFF` +2. `BLOCKED` +3. `DECISION` +4. `INFO` + +Ack policy: +1. Required for `HANDOFF`, `BLOCKED`. +2. Optional for `DECISION`, `INFO`. + +Message schema: +```json +{ + "message_id": "msg_20260213_220001_7f3c", + "thread_id": "bead:bb-dcv.6", + "bead_id": "bb-dcv.6", + "from_agent": "agent-ui-1", + "to_agent": "agent-graph-1", + "category": "HANDOFF", + "subject": "Edge direction patch ready", + "body": "Graph directionality normalized. Please validate screenshots.", + "state": "unread", + "requires_ack": true, + "created_at": "2026-02-13T22:00:01.000Z", + "read_at": null, + "acked_at": null +} +``` + +### 6.1 `bb agent send` + +Input: +1. `--from ` required. +2. `--to ` required. +3. `--bead ` required. +4. `--category ` required. +5. `--subject ` required. +6. `--body ` required. +7. `--thread ` optional (default `bead:`). + +Validation: +1. Sender and recipient must be registered (`broadcast` exempt). +2. `bead_id` required, non-empty. +3. `subject` and `body` non-empty. + +Errors: +1. `UNKNOWN_SENDER` +2. `UNKNOWN_RECIPIENT` +3. `MISSING_BEAD_ID` +4. `INVALID_CATEGORY` + +### 6.2 `bb agent inbox` + +Input: +1. `--agent ` required. +2. `--state ` optional. +3. `--bead ` optional. +4. `--limit ` optional, default `50`, max `500`. + +Output order: +1. `created_at` desc. + +### 6.3 `bb agent read` + +Input: +1. `--agent ` required. +2. `--message ` required. + +Behavior: +1. Mark `state=read` if currently `unread`. +2. Keep `acked` as terminal. + +### 6.4 `bb agent ack` + +Input: +1. `--agent ` required. +2. `--message ` required. + +Validation: +1. Only recipient may ack. +2. `requires_ack=false` messages may still be acked. + +Behavior: +1. Set `state=acked`. +2. Set `acked_at` if null. + +Errors: +1. `MESSAGE_NOT_FOUND` +2. `ACK_FORBIDDEN` + +## 7) Reservation Commands + +Reservation schema: +```json +{ + "reservation_id": "res_20260213_220900_e1a4", + "scope": "src/components/graph/*", + "agent_id": "agent-graph-1", + "bead_id": "bb-dcv.4", + "state": "active", + "created_at": "2026-02-13T22:09:00.000Z", + "expires_at": "2026-02-14T00:09:00.000Z", + "released_at": null +} +``` + +### 7.1 `bb agent reserve` + +Input: +1. `--agent ` required. +2. `--scope ` required. +3. `--bead ` required. +4. `--ttl ` optional, default `120`, range `5..1440`. +5. `--takeover-stale` optional. + +Behavior: +1. If active reservation exists and not expired, fail with `RESERVATION_CONFLICT`. +2. If expired and `--takeover-stale` absent, return `RESERVATION_STALE_FOUND`. +3. If expired and `--takeover-stale`, mark old as expired and create new active record. + +### 7.2 `bb agent release` + +Input: +1. `--agent ` required. +2. `--scope ` required. + +Behavior: +1. Only owner may release active reservation. +2. Mark as `released` and append history event. + +Errors: +1. `RESERVATION_NOT_FOUND` +2. `RELEASE_FORBIDDEN` + +### 7.3 `bb agent status` + +Input: +1. `--bead ` optional. +2. `--agent ` optional. + +Output: +1. Active reservations. +2. Unacked required-ack messages. +3. Optional summary counts by state. + +## 8) Cross-Command Invariants + +1. Every message and reservation must include `bead_id`. +2. Deleting coordination data is disallowed in v1. +3. `message_id` and `reservation_id` are globally unique. +4. All write operations are atomic at file level (write temp + rename). + +## 9) Error Code Registry (v1) + +1. `INVALID_ARGS` +2. `AGENT_NOT_FOUND` +3. `DUPLICATE_AGENT_ID` +4. `UNKNOWN_SENDER` +5. `UNKNOWN_RECIPIENT` +6. `MISSING_BEAD_ID` +7. `INVALID_CATEGORY` +8. `MESSAGE_NOT_FOUND` +9. `ACK_FORBIDDEN` +10. `RESERVATION_CONFLICT` +11. `RESERVATION_STALE_FOUND` +12. `RESERVATION_NOT_FOUND` +13. `RELEASE_FORBIDDEN` +14. `IO_WRITE_FAILED` +15. `IO_READ_FAILED` + +## 10) Test Matrix for Follow-on Tasks + +Identity (`bb-dcv.7`): +1. Register success. +2. Duplicate fails. +3. Force update allowed. +4. Show/list filters. + +Mail (`bb-dcv.6`): +1. Send success. +2. Unknown sender/recipient failure. +3. Inbox state filtering. +4. Read transition (`unread` -> `read`). +5. Ack transition to `acked`. + +Reservations (`bb-dcv.4`): +1. Reserve success. +2. Conflict on active reservation. +3. Expired stale detection. +4. Takeover stale flow. +5. Owner-only release. + +Workflow (`bb-dcv.5`): +1. `bd --claim` + `bb agent` happy path. +2. Missing bead id rejection. +3. Status summary correctness with mixed states. + diff --git a/docs/plans/2026-02-13-timeline-ui-implementation.md b/docs/plans/2026-02-13-timeline-ui-implementation.md new file mode 100644 index 0000000..e817322 --- /dev/null +++ b/docs/plans/2026-02-13-timeline-ui-implementation.md @@ -0,0 +1,53 @@ +# Implementation Plan: Timeline UI (bb-xhm.3) + +## Approach +We will build a dedicated `/timeline` page that consumes `ActivityEvent` streams via SSE and displays them in a grouped, filterable feed. To support data persistence across page refreshes (without DB), we will implement an in-memory ring buffer for events on the server. + +## Steps + +1. **Backend History Buffer** (20 min) + - Modify `src/lib/realtime.ts` to keep last 100 events in `ActivityEventBus`. + - Create `src/app/api/activity/route.ts` to serve this history. + +2. **Scaffold Timeline Route & Store** (15 min) + - Create `src/app/timeline/page.tsx`. + - Create `src/components/timeline/timeline-store.ts` (Zustand). + - Create `src/components/timeline/timeline-layout.tsx`. + +3. **Implement Event Card Components** (30 min) + - Create `src/components/timeline/event-card.tsx`: Polymorphic component. + - Styles: Aero Chrome "glass" panels, status glows, diff formatting. + - **Variants:** + - `StatusEvent`: Status changes with color-coded badges. + - `CommentEvent`: Text bubble style. + - `DiffEvent`: Field-level changes. + - `LifecycleEvent`: Created/Closed/Reopened. + +4. **Implement Feed Container & Grouping** (20 min) + - Create `src/components/timeline/timeline-feed.tsx`. + - Logic: Group `ActivityEvent[]` by `YYYY-MM-DD`. + - Visual: Sticky date headers. + +5. **Wire Real-time SSE & Filters** (20 min) + - Fetch initial history from `/api/activity`. + - Connect `useTimelineStore` to `activityEventBus` (via SSE). + - Implement `TimelineControls`. + +6. **Integration & Polish** (15 min) + - Add navigation links to Kanban (`?focus=bead-id`). + - Verify responsive layout. + +7. **Testing** (20 min) + - Unit tests for store/grouping. + - Component tests for cards. + - Integration test for history API. + +## Timeline +| Phase | Duration | +|-------|----------| +| Backend | 20 min | +| Scaffolding | 15 min | +| UI Components | 50 min | +| Integration | 35 min | +| Testing | 20 min | +| **Total** | **2.5 hours** | \ No newline at end of file diff --git a/package.json b/package.json index c615fe6..e818f15 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/project-context.test.ts && node --import tsx --test tests/lib/project-scope.test.ts && node --import tsx --test tests/lib/aggregate-read.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/graph.test.ts && node --import tsx --test tests/lib/graph-view.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/issue-editor.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs && node --test tests/guards/graph-responsive-contract.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/project-context.test.ts && node --import tsx --test tests/lib/project-scope.test.ts && node --import tsx --test tests/lib/aggregate-read.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/graph.test.ts && node --import tsx --test tests/lib/graph-view.test.ts && node --import tsx --test tests/lib/read-text-retry.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/issue-editor.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/agent-registry.test.ts && node --import tsx --test tests/lib/agent-mail.test.ts && node --import tsx --test tests/lib/agent-reservations.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/skills/beadboard-driver/resolve-bb.test.ts && node --import tsx --test tests/skills/beadboard-driver/generate-agent-name.test.ts && node --import tsx --test tests/skills/beadboard-driver/session-preflight.test.ts && node --import tsx --test tests/skills/beadboard-driver/readiness-report.test.ts && node --import tsx --test tests/skills/beadboard-driver/skill-local-runner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs && node --test tests/guards/graph-responsive-contract.test.mjs" }, "dependencies": { "@xyflow/react": "^12.10.0", diff --git a/src/app/graph/page.tsx b/src/app/graph/page.tsx index 9c7bd83..dbc23ce 100644 --- a/src/app/graph/page.tsx +++ b/src/app/graph/page.tsx @@ -25,6 +25,7 @@ export default async function GraphPage({ searchParams }: GraphPageProps) { mode: scope.mode, selected: scope.selected, scopeOptions: scope.options, + preferBd: true, }); return ( [], edges: Edge[]): Node(null); @@ -922,7 +924,7 @@ export function DependencyGraphPage({ onClose={handleDrawerClose} projectRoot={projectRoot} editable={projectScopeMode === 'single'} - onIssueUpdated={() => router.refresh()} + onIssueUpdated={() => refreshIssues()} blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined} outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []} onSelectBlockedIssue={handleTaskSelect} diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx index a73c3a2..be02456 100644 --- a/src/components/kanban/kanban-card.tsx +++ b/src/components/kanban/kanban-card.tsx @@ -8,6 +8,7 @@ import { hasOpenBlockers } from '../../lib/kanban'; import type { BeadIssue } from '../../lib/types'; import { Chip } from '../shared/chip'; +import { statusBorder, statusDotColor, statusGradient } from '../shared/status-utils'; interface KanbanCardProps { issue: BeadIssue; @@ -21,54 +22,6 @@ interface KanbanCardProps { onSelect: (issue: BeadIssue) => void; } -function statusGradient(status: string): string { - switch (status) { - case 'ready': - case 'open': - return 'bg-[linear-gradient(145deg,rgba(34,45,42,0.92)_0%,rgba(24,32,30,0.88)_50%,rgba(18,28,26,0.9)_100%)]'; - case 'in_progress': - return 'bg-[linear-gradient(145deg,rgba(42,40,32,0.92)_0%,rgba(32,30,24,0.88)_50%,rgba(26,24,18,0.9)_100%)]'; - case 'blocked': - return 'bg-[linear-gradient(145deg,rgba(60,24,30,0.95)_0%,rgba(45,18,24,0.9)_50%,rgba(32,12,16,0.92)_100%)]'; - case 'closed': - return 'bg-[linear-gradient(145deg,rgba(28,30,34,0.75)_0%,rgba(22,24,28,0.72)_50%,rgba(18,20,24,0.75)_100%)] opacity-75'; - default: - return 'bg-[linear-gradient(145deg,rgba(38,40,48,0.92)_0%,rgba(28,30,36,0.88)_50%,rgba(22,24,30,0.9)_100%)]'; - } -} - -function statusBorder(status: string): string { - switch (status) { - case 'ready': - case 'open': - return 'border-emerald-500/20'; - case 'in_progress': - return 'border-amber-500/20'; - case 'blocked': - return 'border-rose-500/20'; - case 'closed': - return 'border-rose-500/30'; - default: - return 'border-white/[0.06]'; - } -} - -function statusDotColor(status: string): string { - switch (status) { - case 'ready': - case 'open': - return 'bg-emerald-400'; - case 'in_progress': - return 'bg-amber-400'; - case 'blocked': - return 'bg-rose-400'; - case 'closed': - return 'bg-slate-400'; - default: - return 'bg-slate-400'; - } -} - function titleColor(status: string): string { return status === 'closed' ? 'text-text-muted/70' : 'text-text-strong/95'; } diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx index 8cf8ab2..c209373 100644 --- a/src/components/kanban/kanban-page.tsx +++ b/src/components/kanban/kanban-page.tsx @@ -2,7 +2,7 @@ import { motion } from 'framer-motion'; import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban'; import { @@ -24,6 +24,8 @@ import { KanbanDetail } from './kanban-detail'; import { ProjectScopeControls } from '../shared/project-scope-controls'; import { WorkspaceHero } from '../shared/workspace-hero'; +import { useBeadsSubscription } from '../../hooks/use-beads-subscription'; + interface KanbanPageProps { issues: BeadIssue[]; projectRoot: string; @@ -34,10 +36,6 @@ interface KanbanPageProps { type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment'; -interface MutationErrorResponse { - error?: { message?: string }; -} - async function postMutation(operation: MutationOperation, body: Record) { const response = await fetch(`/api/beads/${operation}`, { method: 'POST', @@ -51,25 +49,14 @@ async function postMutation(operation: MutationOperation, body: Record { - const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, { - cache: 'no-store', - }); - const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse; - if (!response.ok || !payload.ok || !payload.issues) { - throw new Error(payload.error?.message ?? 'Failed to refresh issues'); - } - return payload.issues; -} - export function KanbanPage({ - issues, + issues: initialIssues, projectRoot, projectScopeKey, projectScopeOptions, projectScopeMode, }: KanbanPageProps) { - const [localIssues, setLocalIssues] = useState(issues); + const { issues: localIssues, refresh: refreshIssues, updateLocal: setLocalIssues } = useBeadsSubscription(initialIssues, projectRoot); const [filters, setFilters] = useState({ query: '', type: '', @@ -83,11 +70,6 @@ export function KanbanPage({ const [nextActionableFeedback, setNextActionableFeedback] = useState(null); const [pendingIssueIds, setPendingIssueIds] = useState>(new Set()); const [mutationError, setMutationError] = useState(null); - const refreshInFlightRef = useRef(false); - - useEffect(() => { - setLocalIssues(issues); - }, [issues]); const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]); const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]); @@ -170,39 +152,6 @@ export function KanbanPage({ selectIssueWithDetailBehavior(nextActionableIssue.id, 'ready'); }, [nextActionableIssue, selectIssueWithDetailBehavior]); - const refreshIssues = useCallback(async (options: { silent?: boolean } = {}) => { - if (refreshInFlightRef.current) { - return; - } - - refreshInFlightRef.current = true; - try { - const reconciled = await fetchIssues(projectRoot); - setLocalIssues(reconciled); - } catch (error) { - if (!options.silent) { - throw error; - } - } finally { - refreshInFlightRef.current = false; - } - }, [projectRoot]); - - // Auto-refresh when issues change on disk (SSE) - useEffect(() => { - const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`); - const onIssues = () => { - void refreshIssues({ silent: true }); - }; - - source.addEventListener('issues', onIssues as EventListener); - - return () => { - source.removeEventListener('issues', onIssues as EventListener); - source.close(); - }; - }, [projectRoot, refreshIssues]); - const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => { if (!allowMutations) { return; diff --git a/src/lib/aggregate-read.ts b/src/lib/aggregate-read.ts index 2c81ce3..e6fe12c 100644 --- a/src/lib/aggregate-read.ts +++ b/src/lib/aggregate-read.ts @@ -51,11 +51,13 @@ export async function readIssuesForScope(options: { mode: 'single' | 'aggregate'; selected: ProjectScopeOption; scopeOptions: ProjectScopeOption[]; + preferBd?: boolean; }): Promise { if (options.mode === 'single') { return readIssuesFromDisk({ projectRoot: options.selected.root, projectSource: options.selected.source, + preferBd: options.preferBd, }); } @@ -64,6 +66,7 @@ export async function readIssuesForScope(options: { const issues = await readIssuesFromDisk({ projectRoot: project.root, projectSource: project.source, + preferBd: options.preferBd, }); return scopeIssuesForProject(project, issues); }), diff --git a/tests/components/sessions/sessions-store.test.ts b/tests/components/sessions/sessions-store.test.ts new file mode 100644 index 0000000..a904ea4 --- /dev/null +++ b/tests/components/sessions/sessions-store.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { useTimelineStore } from '../../../src/components/timeline/timeline-store'; + +describe('Sessions Store (bb-u6f.3.7)', () => { + it('should manage agent and task selection', () => { + const store = useTimelineStore.getState(); + + // Initial state + assert.strictEqual(store.selectedAgentId, null); + assert.strictEqual(store.selectedTaskId, null); + + // Select agent + store.setSelectedAgentId('agent-1'); + assert.strictEqual(useTimelineStore.getState().selectedAgentId, 'agent-1'); + + // Select task + store.setSelectedTaskId('task-1'); + assert.strictEqual(useTimelineStore.getState().selectedTaskId, 'task-1'); + }); + + it('should handle navigation back to agent', () => { + const store = useTimelineStore.getState(); + store.setSelectedAgentId('agent-1'); + store.setSelectedTaskId('task-1'); + + // Back to agent + store.backToAgent(); + assert.strictEqual(useTimelineStore.getState().selectedTaskId, null); + assert.strictEqual(useTimelineStore.getState().selectedAgentId, 'agent-1'); + }); + + it('should clear all selections on clear', () => { + const store = useTimelineStore.getState(); + store.setSelectedAgentId('agent-1'); + store.setSelectedTaskId('task-1'); + + store.clear(); + assert.strictEqual(useTimelineStore.getState().selectedAgentId, null); + assert.strictEqual(useTimelineStore.getState().selectedTaskId, null); + }); +}); diff --git a/tests/hooks/use-beads-subscription.test.ts b/tests/hooks/use-beads-subscription.test.ts new file mode 100644 index 0000000..43d2965 --- /dev/null +++ b/tests/hooks/use-beads-subscription.test.ts @@ -0,0 +1,16 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +// We need a DOM environment to test hooks that use EventSource/fetch +// Since we are running in Node, we can't easily test the hook's effect logic without a heavy setup (JSDOM). +// But we can verify the module loads. + +describe('useBeadsSubscription', () => { + it('should load the module without error', async () => { + try { + await import('../../src/hooks/use-beads-subscription'); + assert.ok(true, 'Module loaded'); + } catch (err) { + assert.fail(err as Error); + } + }); +}); diff --git a/tests/lib/agent-mail.test.ts b/tests/lib/agent-mail.test.ts new file mode 100644 index 0000000..f64a46a --- /dev/null +++ b/tests/lib/agent-mail.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { registerAgent } from '../../src/lib/agent-registry'; +import { ackAgentMessage, inboxAgentMessages, readAgentMessage, sendAgentMessage } from '../../src/lib/agent-mail'; + +async function withTempUserProfile(run: () => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-')); + process.env.USERPROFILE = tempDir; + + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function seedAgents(): Promise { + const now = '2026-02-14T00:00:00.000Z'; + await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now }); + await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now }); +} + +test('sendAgentMessage rejects unknown sender and recipient', async () => { + await withTempUserProfile(async () => { + const unknownSender = await sendAgentMessage({ + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.6', + category: 'HANDOFF', + subject: 'subject', + body: 'body', + }); + + assert.equal(unknownSender.ok, false); + assert.equal(unknownSender.error?.code, 'UNKNOWN_SENDER'); + + await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-14T00:00:00.000Z' }); + + const unknownRecipient = await sendAgentMessage({ + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.6', + category: 'HANDOFF', + subject: 'subject', + body: 'body', + }); + + assert.equal(unknownRecipient.ok, false); + assert.equal(unknownRecipient.error?.code, 'UNKNOWN_RECIPIENT'); + }); +}); + +test('send/inbox/read/ack flows end-to-end', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + const sent = await sendAgentMessage( + { + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.6', + category: 'HANDOFF', + subject: 'Edge direction patch ready', + body: 'Please validate graph screenshots.', + }, + { + now: () => '2026-02-14T00:01:00.000Z', + idGenerator: () => 'msg_20260214_000100_test', + }, + ); + + assert.equal(sent.ok, true); + assert.equal(sent.data?.requires_ack, true); + assert.equal(sent.data?.state, 'unread'); + + const inboxUnread = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'unread' }); + assert.equal(inboxUnread.ok, true); + assert.equal(inboxUnread.data?.length, 1); + + const read = await readAgentMessage( + { agent: 'agent-graph-1', message: 'msg_20260214_000100_test' }, + { now: () => '2026-02-14T00:02:00.000Z' }, + ); + assert.equal(read.ok, true); + assert.equal(read.data?.state, 'read'); + + const ack = await ackAgentMessage( + { agent: 'agent-graph-1', message: 'msg_20260214_000100_test' }, + { now: () => '2026-02-14T00:03:00.000Z' }, + ); + assert.equal(ack.ok, true); + assert.equal(ack.data?.state, 'acked'); + assert.equal(ack.data?.acked_at, '2026-02-14T00:03:00.000Z'); + + const inboxAcked = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'acked' }); + assert.equal(inboxAcked.ok, true); + assert.equal(inboxAcked.data?.length, 1); + }); +}); + +test('ackAgentMessage forbids non-recipient agent', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + await sendAgentMessage( + { + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.6', + category: 'HANDOFF', + subject: 'subject', + body: 'body', + }, + { + now: () => '2026-02-14T00:01:00.000Z', + idGenerator: () => 'msg_20260214_000100_forbidden', + }, + ); + + const forbidden = await ackAgentMessage( + { agent: 'agent-ui-1', message: 'msg_20260214_000100_forbidden' }, + { now: () => '2026-02-14T00:02:00.000Z' }, + ); + + assert.equal(forbidden.ok, false); + assert.equal(forbidden.error?.code, 'ACK_FORBIDDEN'); + }); +}); + +test('sendAgentMessage validates category and bead id', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + const invalidCategory = await sendAgentMessage({ + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.6', + category: 'NOPE' as never, + subject: 'subject', + body: 'body', + }); + assert.equal(invalidCategory.ok, false); + assert.equal(invalidCategory.error?.code, 'INVALID_CATEGORY'); + + const missingBead = await sendAgentMessage({ + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: ' ', + category: 'INFO', + subject: 'subject', + body: 'body', + }); + assert.equal(missingBead.ok, false); + assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID'); + }); +}); diff --git a/tests/lib/agent-registry.test.ts b/tests/lib/agent-registry.test.ts new file mode 100644 index 0000000..e059315 --- /dev/null +++ b/tests/lib/agent-registry.test.ts @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + agentFilePath, + listAgents, + registerAgent, + showAgent, + type AgentRecord, +} from '../../src/lib/agent-registry'; + +async function withTempUserProfile(run: () => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-reg-')); + process.env.USERPROFILE = tempDir; + + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('registerAgent creates stable metadata file with idle status', async () => { + await withTempUserProfile(async () => { + const now = '2026-02-13T23:55:00.000Z'; + const result = await registerAgent( + { + name: 'agent-ui-1', + display: 'UI Agent 1', + role: 'ui', + }, + { now: () => now }, + ); + + assert.equal(result.ok, true); + assert.equal(result.command, 'agent register'); + assert.equal(result.data?.agent_id, 'agent-ui-1'); + assert.equal(result.data?.status, 'idle'); + assert.equal(result.data?.created_at, now); + assert.equal(result.data?.last_seen_at, now); + assert.equal(result.data?.version, 1); + + const file = await fs.readFile(agentFilePath('agent-ui-1'), 'utf8'); + const parsed = JSON.parse(file) as AgentRecord; + assert.equal(parsed.agent_id, 'agent-ui-1'); + assert.equal(parsed.display_name, 'UI Agent 1'); + }); +}); + +test('registerAgent rejects duplicate id without --force-update', async () => { + await withTempUserProfile(async () => { + await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' }); + + const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => '2026-02-13T23:56:00.000Z' }); + + assert.equal(duplicate.ok, false); + assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID'); + }); +}); + +test('registerAgent force update mutates display/role but keeps created_at', async () => { + await withTempUserProfile(async () => { + const first = await registerAgent( + { name: 'agent-ui-1', display: 'UI Agent', role: 'ui' }, + { now: () => '2026-02-13T23:55:00.000Z' }, + ); + assert.equal(first.ok, true); + + const updated = await registerAgent( + { name: 'agent-ui-1', display: 'Frontend Agent', role: 'frontend', forceUpdate: true }, + { now: () => '2026-02-13T23:56:00.000Z' }, + ); + + assert.equal(updated.ok, true); + assert.equal(updated.data?.display_name, 'Frontend Agent'); + assert.equal(updated.data?.role, 'frontend'); + assert.equal(updated.data?.created_at, '2026-02-13T23:55:00.000Z'); + assert.equal(updated.data?.last_seen_at, '2026-02-13T23:56:00.000Z'); + }); +}); + +test('listAgents sorts and filters by role/status', async () => { + await withTempUserProfile(async () => { + await registerAgent({ name: 'agent-b', role: 'backend' }, { now: () => '2026-02-13T23:55:00.000Z' }); + await registerAgent({ name: 'agent-a', role: 'ui' }, { now: () => '2026-02-13T23:55:00.000Z' }); + + await registerAgent( + { name: 'agent-b', role: 'backend', forceUpdate: true }, + { now: () => '2026-02-13T23:56:00.000Z' }, + ); + + const all = await listAgents({}); + assert.equal(all.ok, true); + assert.deepEqual( + all.data?.map((agent) => agent.agent_id), + ['agent-a', 'agent-b'], + ); + + const byRole = await listAgents({ role: 'ui' }); + assert.deepEqual( + byRole.data?.map((agent) => agent.agent_id), + ['agent-a'], + ); + + const byStatus = await listAgents({ status: 'idle' }); + assert.equal(byStatus.ok, true); + assert.equal(byStatus.data?.length, 2); + }); +}); + +test('showAgent returns AGENT_NOT_FOUND for unknown id', async () => { + await withTempUserProfile(async () => { + const result = await showAgent({ agent: 'agent-missing' }); + assert.equal(result.ok, false); + assert.equal(result.error?.code, 'AGENT_NOT_FOUND'); + }); +}); + +test('registerAgent validates id pattern and role', async () => { + await withTempUserProfile(async () => { + const badName = await registerAgent({ name: 'Agent_Upper', role: 'ui' }); + assert.equal(badName.ok, false); + assert.equal(badName.error?.code, 'INVALID_AGENT_ID'); + + const badRole = await registerAgent({ name: 'agent-ok-1', role: ' ' }); + assert.equal(badRole.ok, false); + assert.equal(badRole.error?.code, 'INVALID_ROLE'); + }); +}); diff --git a/tests/lib/agent-reservations.test.ts b/tests/lib/agent-reservations.test.ts new file mode 100644 index 0000000..6ca7c19 --- /dev/null +++ b/tests/lib/agent-reservations.test.ts @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { registerAgent } from '../../src/lib/agent-registry'; +import { sendAgentMessage } from '../../src/lib/agent-mail'; +import { releaseAgentReservation, reserveAgentScope, statusAgentReservations } from '../../src/lib/agent-reservations'; + +async function withTempUserProfile(run: () => Promise): Promise { + const previous = process.env.USERPROFILE; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-reservations-')); + process.env.USERPROFILE = tempDir; + + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previous; + } + + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function seedAgents(): Promise { + const now = '2026-02-14T00:00:00.000Z'; + await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now }); + await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now }); +} + +test('reserve/release/status flows with required-ack status visibility', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + const reserved = await reserveAgentScope( + { + agent: 'agent-ui-1', + scope: 'src/components/graph/*', + bead: 'bb-dcv.4', + }, + { + now: () => '2026-02-14T00:01:00.000Z', + idGenerator: () => 'res_20260214_000100_flow', + }, + ); + + assert.equal(reserved.ok, true); + assert.equal(reserved.data?.reservation_id, 'res_20260214_000100_flow'); + + await sendAgentMessage( + { + from: 'agent-ui-1', + to: 'agent-graph-1', + bead: 'bb-dcv.4', + category: 'HANDOFF', + subject: 'handoff', + body: 'please review', + }, + { + now: () => '2026-02-14T00:02:00.000Z', + idGenerator: () => 'msg_20260214_000200_flow', + }, + ); + + const statusBeforeRelease = await statusAgentReservations({ bead: 'bb-dcv.4' }, { now: () => '2026-02-14T00:03:00.000Z' }); + assert.equal(statusBeforeRelease.ok, true); + assert.equal(statusBeforeRelease.data?.reservations.length, 1); + assert.equal(statusBeforeRelease.data?.unacked_required_messages.length, 1); + + const released = await releaseAgentReservation( + { + agent: 'agent-ui-1', + scope: 'src/components/graph/*', + }, + { now: () => '2026-02-14T00:04:00.000Z' }, + ); + + assert.equal(released.ok, true); + assert.equal(released.data?.state, 'released'); + + const statusAfterRelease = await statusAgentReservations({ bead: 'bb-dcv.4' }, { now: () => '2026-02-14T00:05:00.000Z' }); + assert.equal(statusAfterRelease.ok, true); + assert.equal(statusAfterRelease.data?.reservations.length, 0); + }); +}); + +test('status clears expired reservations after TTL elapses', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + const reserved = await reserveAgentScope( + { + agent: 'agent-ui-1', + scope: 'src/components/kanban/*', + bead: 'bb-dcv.4', + ttl: 5, + }, + { + now: () => '2026-02-14T00:00:00.000Z', + idGenerator: () => 'res_20260214_000000_expire', + }, + ); + assert.equal(reserved.ok, true); + + const status = await statusAgentReservations({}, { now: () => '2026-02-14T00:06:00.000Z' }); + assert.equal(status.ok, true); + assert.equal(status.data?.reservations.length, 0); + assert.equal(status.data?.summary.expired, 1); + }); +}); + +test('stale reservation conflict and takeover behavior', async () => { + await withTempUserProfile(async () => { + await seedAgents(); + + const initial = await reserveAgentScope( + { + agent: 'agent-ui-1', + scope: 'src/components/workspace/*', + bead: 'bb-dcv.4', + ttl: 5, + }, + { + now: () => '2026-02-14T00:00:00.000Z', + idGenerator: () => 'res_20260214_000000_stale', + }, + ); + assert.equal(initial.ok, true); + + const staleConflict = await reserveAgentScope( + { + agent: 'agent-graph-1', + scope: 'src/components/workspace/*', + bead: 'bb-dcv.4', + ttl: 5, + }, + { + now: () => '2026-02-14T00:06:00.000Z', + idGenerator: () => 'res_20260214_000600_takeover', + }, + ); + + assert.equal(staleConflict.ok, false); + assert.equal(staleConflict.error?.code, 'RESERVATION_STALE_FOUND'); + + const takeover = await reserveAgentScope( + { + agent: 'agent-graph-1', + scope: 'src/components/workspace/*', + bead: 'bb-dcv.4', + ttl: 5, + takeoverStale: true, + }, + { + now: () => '2026-02-14T00:06:00.000Z', + idGenerator: () => 'res_20260214_000600_takeover', + }, + ); + + assert.equal(takeover.ok, true); + assert.equal(takeover.data?.agent_id, 'agent-graph-1'); + + const wrongRelease = await releaseAgentReservation( + { + agent: 'agent-ui-1', + scope: 'src/components/workspace/*', + }, + { now: () => '2026-02-14T00:07:00.000Z' }, + ); + + assert.equal(wrongRelease.ok, false); + assert.equal(wrongRelease.error?.code, 'RELEASE_FORBIDDEN'); + }); +}); diff --git a/tests/lib/watcher.test.ts b/tests/lib/watcher.test.ts index 86bb24e..41cf63f 100644 --- a/tests/lib/watcher.test.ts +++ b/tests/lib/watcher.test.ts @@ -4,15 +4,15 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { IssuesEventBus } from '../../src/lib/realtime'; +import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime'; import { IssuesWatchManager } from '../../src/lib/watcher'; test('IssuesWatchManager startWatch is idempotent per project', async () => { const bus = new IssuesEventBus(); const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 }); - manager.startWatch('C:/Repo/One'); - manager.startWatch('c:\\repo\\one'); + await manager.startWatch('C:/Repo/One'); + await manager.startWatch('c:\\repo\\one'); assert.equal(manager.getWatchedProjectCount(), 1); await manager.stopAll(); @@ -33,7 +33,7 @@ test('IssuesWatchManager emits event after file change in watched .beads path', events.push(event.projectRoot); }); - manager.startWatch(root); + await manager.startWatch(root); await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8'); await new Promise((resolve) => setTimeout(resolve, 220)); @@ -43,3 +43,99 @@ test('IssuesWatchManager emits event after file change in watched .beads path', assert.equal(events.length >= 1, true); }); + +test('IssuesWatchManager emits event after beads.db change', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-')); + const beadsDir = path.join(root, '.beads'); + const dbPath = path.join(beadsDir, 'beads.db'); + await fs.mkdir(beadsDir, { recursive: true }); + await fs.writeFile(dbPath, 'seed', 'utf8'); + + const bus = new IssuesEventBus(); + const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 }); + + const events: string[] = []; + const stop = bus.subscribe((event) => { + events.push(event.projectRoot); + }); + + await manager.startWatch(root); + + await fs.writeFile(dbPath, `seed-${Date.now()}`, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 220)); + + stop(); + await manager.stopAll(); + + assert.equal(events.length >= 1, true); +}); + +test('IssuesWatchManager emits event after beads.db-wal change', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-wal-')); + const beadsDir = path.join(root, '.beads'); + const walPath = path.join(beadsDir, 'beads.db-wal'); + await fs.mkdir(beadsDir, { recursive: true }); + await fs.writeFile(walPath, 'seed', 'utf8'); + + const bus = new IssuesEventBus(); + const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 }); + + const events: string[] = []; + const stop = bus.subscribe((event) => { + events.push(event.projectRoot); + }); + + await manager.startWatch(root); + + await fs.writeFile(walPath, `seed-${Date.now()}`, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 220)); + + stop(); + await manager.stopAll(); + + assert.equal(events.length >= 1, true); +}); + +test('IssuesWatchManager emits ActivityEvent on issue change', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-')); + const beadsDir = path.join(root, '.beads'); + const issuesPath = path.join(beadsDir, 'issues.jsonl'); + + await fs.mkdir(beadsDir, { recursive: true }); + + // Initial state: 1 issue + const issuev1 = { id: 'bb-1', title: 'Task A', status: 'open' }; + await fs.writeFile(issuesPath, JSON.stringify(issuev1) + '\n', 'utf8'); + + const issuesBus = new IssuesEventBus(); + const activityBus = new ActivityEventBus(); + const manager = new IssuesWatchManager({ + eventBus: issuesBus, + activityBus, + debounceMs: 50 + }); + + const activities: string[] = []; + const stop = activityBus.subscribe((e) => { + activities.push(`${e.event.kind}:${e.event.beadId}`); + }); + + // Start watching (should load initial snapshot silently) + await manager.startWatch(root); + + // Wait for initial read to settle + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Modify issue: status change + const issuev2 = { ...issuev1, status: 'in_progress' }; + await fs.writeFile(issuesPath, JSON.stringify(issuev2) + '\n', 'utf8'); + + // Wait for debounce + processing + await new Promise((resolve) => setTimeout(resolve, 300)); + + stop(); + await manager.stopAll(); + + // Expect status_changed for bb-1 + assert.ok(activities.includes('status_changed:bb-1'), `Expected status_changed event. Got: ${activities.join(', ')}`); +}); diff --git a/tests/skills/beadboard-driver/generate-agent-name.test.ts b/tests/skills/beadboard-driver/generate-agent-name.test.ts new file mode 100644 index 0000000..f701c17 --- /dev/null +++ b/tests/skills/beadboard-driver/generate-agent-name.test.ts @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const scriptPath = path.resolve('skills/beadboard-driver/scripts/generate-agent-name.mjs'); + +async function runName(env: Record = {}) { + const { stdout } = await execFileAsync('node', [scriptPath], { + env: { ...process.env, ...env }, + }); + return JSON.parse(stdout); +} + +async function withTempDir(run: (root: string) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-name-')); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +test('generate-agent-name returns adjective-noun format', async () => { + const result = await runName({ + BB_NAME_ADJECTIVES: 'green', + BB_NAME_NOUNS: 'castle', + BB_NAME_MAX_RETRIES: '1', + }); + + assert.equal(result.ok, true); + assert.equal(result.agent_name, 'green-castle'); + assert.match(result.agent_name, /^[a-z0-9]+(?:-[a-z0-9]+)*$/); +}); + +test('generate-agent-name retries on collisions', async () => { + await withTempDir(async (root) => { + const registryDir = path.join(root, 'agents'); + await fs.mkdir(registryDir, { recursive: true }); + await fs.writeFile(path.join(registryDir, 'green-castle.json'), '{}', 'utf8'); + + const result = await runName({ + BB_AGENT_REGISTRY_DIR: registryDir, + BB_NAME_ADJECTIVES: 'green,blue', + BB_NAME_NOUNS: 'castle', + BB_NAME_MAX_RETRIES: '3', + BB_NAME_SEED_SEQUENCE: '0,0,0.9,0', + }); + + assert.equal(result.ok, true); + assert.equal(result.agent_name, 'blue-castle'); + assert.equal(result.collisions, 2); + assert.equal(result.attempts, 3); + }); +}); + +test('generate-agent-name fails after retry exhaustion', async () => { + await withTempDir(async (root) => { + const registryDir = path.join(root, 'agents'); + await fs.mkdir(registryDir, { recursive: true }); + await fs.writeFile(path.join(registryDir, 'green-castle.json'), '{}', 'utf8'); + + const result = await runName({ + BB_AGENT_REGISTRY_DIR: registryDir, + BB_NAME_ADJECTIVES: 'green', + BB_NAME_NOUNS: 'castle', + BB_NAME_MAX_RETRIES: '2', + BB_NAME_SEED_SEQUENCE: '0,0,0,0', + }); + + assert.equal(result.ok, false); + assert.equal(result.error_code, 'NAME_GENERATION_EXHAUSTED'); + assert.equal(result.attempts, 2); + }); +}); diff --git a/tests/skills/beadboard-driver/readiness-report.test.ts b/tests/skills/beadboard-driver/readiness-report.test.ts new file mode 100644 index 0000000..2b4b2b7 --- /dev/null +++ b/tests/skills/beadboard-driver/readiness-report.test.ts @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const scriptPath = path.resolve('skills/beadboard-driver/scripts/readiness-report.mjs'); + +async function runReport(args: string[]) { + const { stdout } = await execFileAsync('node', [scriptPath, ...args], { + env: process.env, + }); + return JSON.parse(stdout); +} + +async function withTempDir(run: (root: string) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-report-')); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +test('readiness-report outputs stable schema', async () => { + await withTempDir(async (root) => { + const artifact = path.join(root, 'artifact.txt'); + await fs.writeFile(artifact, 'ok', 'utf8'); + + const checks = JSON.stringify([ + { name: 'typecheck', ok: true, details: 'pass' }, + { name: 'test', ok: true, details: 'pass' }, + ]); + const artifacts = JSON.stringify([{ path: artifact, required: true }]); + + const result = await runReport(['--checks', checks, '--artifacts', artifacts, '--dependency-note', 'acyclic']); + + assert.equal(result.ok, true); + assert.equal(result.summary.ready, true); + assert.equal(result.checks.length, 2); + assert.equal(result.artifacts[0].exists, true); + assert.equal(result.dependency_sanity, 'acyclic'); + }); +}); + +test('readiness-report flags missing required artifact', async () => { + const checks = JSON.stringify([{ name: 'lint', ok: true, details: 'pass' }]); + const artifacts = JSON.stringify([{ path: 'missing.png', required: true }]); + + const result = await runReport(['--checks', checks, '--artifacts', artifacts]); + assert.equal(result.ok, true); + assert.equal(result.summary.ready, false); + assert.equal(result.artifacts[0].exists, false); +}); diff --git a/tests/skills/beadboard-driver/resolve-bb.test.ts b/tests/skills/beadboard-driver/resolve-bb.test.ts new file mode 100644 index 0000000..c480d6a --- /dev/null +++ b/tests/skills/beadboard-driver/resolve-bb.test.ts @@ -0,0 +1,137 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const scriptPath = path.resolve('skills/beadboard-driver/scripts/resolve-bb.mjs'); + +async function runResolve(env: Record = {}) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { ...process.env, ...env }, + }); + return JSON.parse(stdout); +} + +async function withTempDir(run: (root: string) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-resolve-')); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +test('resolve-bb uses BB_REPO and returns env source', async () => { + await withTempDir(async (root) => { + const repo = path.join(root, 'beadboard'); + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + + const result = await runResolve({ + BB_REPO: repo, + BB_SKILL_HOME: path.join(root, 'home'), + PATH: '', + }); + + assert.equal(result.ok, true); + assert.equal(result.source, 'env'); + assert.equal(result.resolved_path, path.join(repo, 'bb.ps1')); + }); +}); + +test('resolve-bb fails with remediation when BB_REPO is invalid', async () => { + await withTempDir(async (root) => { + const result = await runResolve({ + BB_REPO: path.join(root, 'missing'), + BB_SKILL_HOME: path.join(root, 'home'), + PATH: '', + }); + + assert.equal(result.ok, false); + assert.equal(result.source, 'env'); + assert.match(result.reason, /BB_REPO/i); + assert.match(result.remediation, /Set BB_REPO/i); + }); +}); + +test('resolve-bb uses cache when env and global are unavailable', async () => { + await withTempDir(async (root) => { + const repo = path.join(root, 'beadboard'); + const home = path.join(root, 'home'); + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + await fs.mkdir(path.join(home, '.beadboard'), { recursive: true }); + await fs.writeFile( + path.join(home, '.beadboard', 'skill-config.json'), + JSON.stringify({ bb_path: path.join(repo, 'bb.ps1') }, null, 2), + 'utf8', + ); + + const result = await runResolve({ + BB_SKILL_HOME: home, + PATH: '', + }); + + assert.equal(result.ok, true); + assert.equal(result.source, 'cache'); + }); +}); + +test('resolve-bb discovers repo and self-updates cache', async () => { + await withTempDir(async (root) => { + const repo = path.join(root, 'workspace', 'beadboard'); + const home = path.join(root, 'home'); + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + + const result = await runResolve({ + BB_SKILL_HOME: home, + BB_SEARCH_ROOTS: path.join(root, 'workspace'), + PATH: '', + }); + + assert.equal(result.ok, true); + assert.equal(result.source, 'discovery'); + + const cacheRaw = await fs.readFile(path.join(home, '.beadboard', 'skill-config.json'), 'utf8'); + const cache = JSON.parse(cacheRaw); + assert.equal(cache.bb_path, path.join(repo, 'bb.ps1')); + }); +}); + +test('resolve-bb uses BB_REPO over cache and rewrites stale cache', async () => { + await withTempDir(async (root) => { + const repoA = path.join(root, 'repo-a'); + const repoB = path.join(root, 'repo-b'); + const home = path.join(root, 'home'); + + await fs.mkdir(path.join(repoA, 'tools'), { recursive: true }); + await fs.mkdir(path.join(repoB, 'tools'), { recursive: true }); + await fs.writeFile(path.join(repoA, 'bb.ps1'), 'echo a', 'utf8'); + await fs.writeFile(path.join(repoB, 'bb.ps1'), 'echo b', 'utf8'); + await fs.mkdir(path.join(home, '.beadboard'), { recursive: true }); + await fs.writeFile( + path.join(home, '.beadboard', 'skill-config.json'), + JSON.stringify({ bb_path: path.join(repoA, 'bb.ps1') }, null, 2), + 'utf8', + ); + + const result = await runResolve({ + BB_REPO: repoB, + BB_SKILL_HOME: home, + PATH: '', + }); + + assert.equal(result.ok, true); + assert.equal(result.source, 'env'); + assert.match(result.reason, /cache mismatch/i); + + const cacheRaw = await fs.readFile(path.join(home, '.beadboard', 'skill-config.json'), 'utf8'); + const cache = JSON.parse(cacheRaw); + assert.equal(cache.bb_path, path.join(repoB, 'bb.ps1')); + }); +}); diff --git a/tests/skills/beadboard-driver/session-preflight.test.ts b/tests/skills/beadboard-driver/session-preflight.test.ts new file mode 100644 index 0000000..7f174a4 --- /dev/null +++ b/tests/skills/beadboard-driver/session-preflight.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const scriptPath = path.resolve('skills/beadboard-driver/scripts/session-preflight.mjs'); + +async function runPreflight(env: Record = {}) { + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { ...process.env, ...env }, + }); + return JSON.parse(stdout); +} + +async function withTempDir(run: (root: string) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-preflight-')); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +test('session-preflight fails when bd is unavailable', async () => { + const result = await runPreflight({ + PATH: '', + }); + + assert.equal(result.ok, false); + assert.equal(result.error_code, 'BD_NOT_FOUND'); +}); + +test('session-preflight succeeds with fake bd and BB_REPO', async () => { + await withTempDir(async (root) => { + const repo = path.join(root, 'beadboard'); + const toolsDir = path.join(root, 'tools'); + const bdCmd = path.join(toolsDir, 'bd.cmd'); + + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.mkdir(toolsDir, { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + await fs.writeFile(bdCmd, '@echo off\r\necho beads\r\n', 'utf8'); + + const result = await runPreflight({ + PATH: toolsDir, + BB_REPO: repo, + BB_SKILL_HOME: path.join(root, 'home'), + BB_SKIP_PROBE: '1', + }); + + assert.equal(result.ok, true); + assert.equal(result.bb.ok, true); + assert.equal(result.bb.source, 'env'); + assert.equal(result.tools.bd.available, true); + }); +}); diff --git a/tests/skills/beadboard-driver/skill-local-runner.test.ts b/tests/skills/beadboard-driver/skill-local-runner.test.ts new file mode 100644 index 0000000..7d73d2b --- /dev/null +++ b/tests/skills/beadboard-driver/skill-local-runner.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +test('skill-local runner passes', async () => { + const runnerPath = path.resolve('skills/beadboard-driver/tests/run-tests.mjs'); + const { stdout, stderr } = await execFileAsync(process.execPath, [runnerPath], { + env: process.env, + }); + assert.doesNotMatch(`${stdout}\n${stderr}`, /not ok/i); +}); diff --git a/tools/bb.ts b/tools/bb.ts new file mode 100644 index 0000000..9e61463 --- /dev/null +++ b/tools/bb.ts @@ -0,0 +1,279 @@ +import { parseArgs } from 'node:util'; +import { + registerAgent, listAgents, showAgent, type AgentCommandResponse +} from '../src/lib/agent-registry'; +import { + sendAgentMessage, inboxAgentMessages, readAgentMessage, ackAgentMessage, + type MailCommandResponse, type MessageCategory +} from '../src/lib/agent-mail'; +import { + reserveAgentScope, releaseAgentReservation, statusAgentReservations, + type ReservationCommandResponse +} from '../src/lib/agent-reservations'; + +// Common types +type AnyCommandResponse = AgentCommandResponse | MailCommandResponse | ReservationCommandResponse; + +function stringArg(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function booleanArg(value: string | boolean | undefined): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +// Helper to print response +function printResponse(response: AnyCommandResponse, json: boolean) { + if (json) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (!response.ok) { + console.error(`Error: [${response.error?.code}] ${response.error?.message}`); + process.exit(1); + } + + // Human readable mapping + if (response.command === 'agent register') { + const d = response.data; + console.log(`✓ Agent registered: ${d.agent_id} (role: ${d.role}, status: ${d.status})`); + } else if (response.command === 'agent list') { + const list = response.data as any[]; + console.log(`Found ${list.length} agents:`); + list.forEach(a => console.log(`- ${a.agent_id} (${a.role}) [${a.status}]`)); + } else if (response.command === 'agent show') { + const d = response.data; + console.log(`Agent: ${d.agent_id}\nRole: ${d.role}\nStatus: ${d.status}\nLast Seen: ${d.last_seen_at}`); + } else if (response.command === 'agent send') { + const d = response.data; + console.log(`✓ Message sent: ${d.message_id} (state: ${d.state})`); + } else if (response.command === 'agent inbox') { + const list = response.data as any[]; + console.log(`Inbox (${list.length}):`); + list.forEach(m => console.log(`- [${m.message_id}] ${m.category}: ${m.subject} (from: ${m.from_agent})`)); + } else if (response.command === 'agent read') { + const d = response.data; + console.log(`✓ Message read: ${d.message_id} (state: ${d.state})`); + } else if (response.command === 'agent ack') { + const d = response.data; + console.log(`✓ Message acked: ${d.message_id} (state: ${d.state})`); + } else if (response.command === 'agent reserve') { + const d = response.data; + console.log(`✓ Scope reserved: ${d.reservation_id}\nScope: ${d.scope}\nExpires: ${d.expires_at}`); + } else if (response.command === 'agent release') { + const d = response.data; + console.log(`✓ Reservation released. State: ${d.state}`); + } else if (response.command === 'agent status') { + const d = response.data; + console.log(`Active Reservations: ${d.reservations.length}`); + d.reservations.forEach((r: any) => console.log(`- ${r.scope} (agent: ${r.agent_id}, expires: ${r.expires_at})`)); + console.log(`Unacked Required Messages: ${d.unacked_required_messages.length}`); + } else { + console.log('Success:', response.data); + } +} + +function printAgentHelp() { + console.log(`Usage: bb agent [options] + +Commands: + register Register or update an agent identity + list List registered agents + show Show one registered agent + send Send a message to an agent + inbox List inbox messages for an agent + read Mark one message as read + ack Acknowledge one message + reserve Reserve a work scope + release Release a reservation scope + status Show reservation/message status + +Naming policy: + - Use a unique agent name per session. + - Prefer adjective-noun names (example: amber-otter, cobalt-harbor). + - Do not reuse a prior session identity. + +Examples: + bb agent list --json + bb agent register --name amber-otter --role ui + bb agent status --agent amber-otter +`); +} + +async function main() { + const args = process.argv.slice(2); + if (args.length === 0) { + printAgentHelp(); + process.exit(0); + } + + // Very simple manual parsing for subcommand routing since parseArgs is flat + const domain = args[0]; // agent + const command = args[1]; // register, list, etc + + if (domain === '--help' || domain === '-h' || domain === 'help') { + printAgentHelp(); + process.exit(0); + } + + if (domain !== 'agent') { + console.error('Only "agent" domain supported currently.'); + process.exit(1); + } + + if (!command || command === '--help' || command === '-h' || command === 'help') { + printAgentHelp(); + process.exit(0); + } + + // Parse remaining args + const { values } = parseArgs({ + args: args.slice(2), + options: { + // Identity + name: { type: 'string' }, + role: { type: 'string' }, + display: { type: 'string' }, + 'force-update': { type: 'boolean' }, + agent: { type: 'string' }, // shared + status: { type: 'string' }, // shared + + // Mail + from: { type: 'string' }, + to: { type: 'string' }, + bead: { type: 'string' }, + category: { type: 'string' }, + subject: { type: 'string' }, + body: { type: 'string' }, + thread: { type: 'string' }, + state: { type: 'string' }, + message: { type: 'string' }, + limit: { type: 'string' }, // Note: parseArgs strings, convert to number + + // Reservations + scope: { type: 'string' }, + ttl: { type: 'string' }, + 'takeover-stale': { type: 'boolean' }, + + // Output + json: { type: 'boolean' }, + }, + strict: false, + }); + + const json = booleanArg(values.json) ?? false; + // Shim deps + const deps = {}; + + try { + let result: AnyCommandResponse; + + switch (command) { + // --- Identity --- + case 'register': + if (!values.name || !values.role) throw new Error('--name and --role required'); + result = await registerAgent({ + name: stringArg(values.name)!, + role: stringArg(values.role)!, + display: stringArg(values.display), + forceUpdate: booleanArg(values['force-update']), + }, deps); + break; + + case 'list': + result = await listAgents({ + role: stringArg(values.role), + status: stringArg(values.status), + }); + break; + + case 'show': + if (!values.agent) throw new Error('--agent required'); + result = await showAgent({ agent: stringArg(values.agent)! }); + break; + + // --- Mail --- + case 'send': + if (!values.from || !values.to || !values.bead || !values.category || !values.subject || !values.body) { + throw new Error('--from, --to, --bead, --category, --subject, --body required'); + } + result = await sendAgentMessage({ + from: stringArg(values.from)!, + to: stringArg(values.to)!, + bead: stringArg(values.bead)!, + category: stringArg(values.category)! as MessageCategory, + subject: stringArg(values.subject)!, + body: stringArg(values.body)!, + thread: stringArg(values.thread), + }, deps); + break; + + case 'inbox': + if (!values.agent) throw new Error('--agent required'); + result = await inboxAgentMessages({ + agent: stringArg(values.agent)!, + state: stringArg(values.state) as any, + bead: stringArg(values.bead), + limit: stringArg(values.limit) ? parseInt(stringArg(values.limit)!, 10) : undefined, + }); + break; + + case 'read': + if (!values.agent || !values.message) throw new Error('--agent and --message required'); + result = await readAgentMessage({ agent: stringArg(values.agent)!, message: stringArg(values.message)! }, deps); + break; + + case 'ack': + if (!values.agent || !values.message) throw new Error('--agent and --message required'); + result = await ackAgentMessage({ agent: stringArg(values.agent)!, message: stringArg(values.message)! }, deps); + break; + + // --- Reservations --- + case 'reserve': + if (!values.agent || !values.scope || !values.bead) throw new Error('--agent, --scope, --bead required'); + result = await reserveAgentScope({ + agent: stringArg(values.agent)!, + scope: stringArg(values.scope)!, + bead: stringArg(values.bead)!, + ttl: stringArg(values.ttl) ? parseInt(stringArg(values.ttl)!, 10) : undefined, + takeoverStale: booleanArg(values['takeover-stale']), + }, deps); + break; + + case 'release': + if (!values.agent || !values.scope) throw new Error('--agent and --scope required'); + result = await releaseAgentReservation({ agent: stringArg(values.agent)!, scope: stringArg(values.scope)! }, deps); + break; + + case 'status': + // status is optional input + result = await statusAgentReservations({ + bead: stringArg(values.bead), + agent: stringArg(values.agent) + }, deps); + break; + + default: + console.error(`Unknown command: ${command}`); + process.exit(1); + } + + printResponse(result, json); + + } catch (error) { + if (json) { + console.log(JSON.stringify({ + ok: false, + command: `agent ${command}`, + data: null, + error: { code: 'CLI_ERROR', message: error instanceof Error ? error.message : String(error) } + }, null, 2)); + } else { + console.error('Error:', error instanceof Error ? error.message : String(error)); + } + process.exit(1); + } +} + +main();