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.
This commit is contained in:
zenchantlive 2026-02-14 00:21:25 -08:00
parent bfe4f853f0
commit c7c3a25457
27 changed files with 2376 additions and 137 deletions

138
AGENTS.md
View file

@ -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 <id> # View issue details
bd update <id> --status in_progress # Claim work
bd close <id> # Complete work
bd sync # Sync with git
bd ready
bd show <id>
bd update <id> --status in_progress --notes "<plan>"
bd update <id> --notes "<progress/evidence>"
bd close <id> --reason "<what was completed>"
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 <id>`).
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 <id> --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 <id> --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.

6
bb.ps1 Normal file
View file

@ -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

View file

@ -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`

165
docs/agent-session-flow.md Normal file
View file

@ -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.

29
docs/features/timeline.md Normal file
View file

@ -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.*

View file

@ -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

View file

@ -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/<agent_id>.json`
2. `.beadboard/agent/messages/<agent_id>.jsonl` (recipient inbox stream)
3. `.beadboard/agent/messages/index/<message_id>.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 <agent_id>` required.
2. `--display <display_name>` optional.
3. `--role <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/<agent_id>.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 <role>` optional filter.
2. `--status <status>` optional filter.
Output:
1. Sorted by `agent_id` asc.
### 5.3 `bb agent show`
Input:
1. `--agent <agent_id>` 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 <agent_id>` required.
2. `--to <agent_id|broadcast>` required.
3. `--bead <bead_id>` required.
4. `--category <HANDOFF|BLOCKED|DECISION|INFO>` required.
5. `--subject <text>` required.
6. `--body <text>` required.
7. `--thread <thread_id>` optional (default `bead:<bead_id>`).
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 <agent_id>` required.
2. `--state <unread|read|acked>` optional.
3. `--bead <bead_id>` optional.
4. `--limit <n>` optional, default `50`, max `500`.
Output order:
1. `created_at` desc.
### 6.3 `bb agent read`
Input:
1. `--agent <agent_id>` required.
2. `--message <message_id>` required.
Behavior:
1. Mark `state=read` if currently `unread`.
2. Keep `acked` as terminal.
### 6.4 `bb agent ack`
Input:
1. `--agent <agent_id>` required.
2. `--message <message_id>` 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 <agent_id>` required.
2. `--scope <scope>` required.
3. `--bead <bead_id>` required.
4. `--ttl <minutes>` 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 <agent_id>` required.
2. `--scope <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 <bead_id>` optional.
2. `--agent <agent_id>` 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.

View file

@ -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** |

View file

@ -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",

View file

@ -25,6 +25,7 @@ export default async function GraphPage({ searchParams }: GraphPageProps) {
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
});
return (
<DependencyGraphPage

View file

@ -23,6 +23,7 @@ export default async function Page({ searchParams }: PageProps) {
mode: scope.mode,
selected: scope.selected,
scopeOptions: scope.options,
preferBd: true,
});
return (
<KanbanPage

View file

@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
MarkerType,
@ -36,6 +36,8 @@ import { buildBlockedByTree } from '../../lib/kanban';
import { type BeadIssue } from '../../lib/types';
import type { ProjectScopeOption } from '../../lib/project-scope';
import { useBeadsSubscription } from '../../hooks/use-beads-subscription';
/** Props for the DependencyGraphPage component. */
interface DependencyGraphPageProps {
/** All issues in the project. */
@ -110,13 +112,13 @@ function layoutDagre(nodes: Node<GraphNodeData>[], edges: Edge[]): Node<GraphNod
* - Dependencies tab: flow strip + ReactFlow graph
*/
export function DependencyGraphPage({
issues,
issues: initialIssues,
projectRoot,
projectScopeKey,
projectScopeOptions,
projectScopeMode,
}: DependencyGraphPageProps) {
const router = useRouter();
const { issues, refresh: refreshIssues } = useBeadsSubscription(initialIssues, projectRoot);
const searchParams = useSearchParams();
// --- State ---
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(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}

View file

@ -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';
}

View file

@ -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<string, unknown>) {
const response = await fetch(`/api/beads/${operation}`, {
method: 'POST',
@ -51,25 +49,14 @@ async function postMutation(operation: MutationOperation, body: Record<string, u
}
}
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
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<BeadIssue[]>(issues);
const { issues: localIssues, refresh: refreshIssues, updateLocal: setLocalIssues } = useBeadsSubscription(initialIssues, projectRoot);
const [filters, setFilters] = useState<KanbanFilterOptions>({
query: '',
type: '',
@ -83,11 +70,6 @@ export function KanbanPage({
const [nextActionableFeedback, setNextActionableFeedback] = useState<string | null>(null);
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
const [mutationError, setMutationError] = useState<string | null>(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;

View file

@ -51,11 +51,13 @@ export async function readIssuesForScope(options: {
mode: 'single' | 'aggregate';
selected: ProjectScopeOption;
scopeOptions: ProjectScopeOption[];
preferBd?: boolean;
}): Promise<BeadIssueWithProject[]> {
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);
}),

View file

@ -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);
});
});

View file

@ -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);
}
});
});

View file

@ -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<void>): Promise<void> {
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<void> {
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');
});
});

View file

@ -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<void>): Promise<void> {
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');
});
});

View file

@ -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<void>): Promise<void> {
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<void> {
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');
});
});

View file

@ -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(', ')}`);
});

View file

@ -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<string, string | undefined> = {}) {
const { stdout } = await execFileAsync('node', [scriptPath], {
env: { ...process.env, ...env },
});
return JSON.parse(stdout);
}
async function withTempDir(run: (root: string) => Promise<void>) {
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);
});
});

View file

@ -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<void>) {
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);
});

View file

@ -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<string, string | undefined> = {}) {
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
env: { ...process.env, ...env },
});
return JSON.parse(stdout);
}
async function withTempDir(run: (root: string) => Promise<void>) {
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'));
});
});

View file

@ -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<string, string | undefined> = {}) {
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
env: { ...process.env, ...env },
});
return JSON.parse(stdout);
}
async function withTempDir(run: (root: string) => Promise<void>) {
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);
});
});

View file

@ -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);
});

279
tools/bb.ts Normal file
View file

@ -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<any> | MailCommandResponse<any> | ReservationCommandResponse<any>;
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 <command> [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();