Merge remote-tracking branch 'origin/main' into performance-fix

This commit is contained in:
Steve Yegge
2025-12-14 14:53:30 -08:00
18 changed files with 11336 additions and 866 deletions

42
.beads/.gitignore vendored
View File

@@ -1,20 +1,32 @@
# Ignore all .beads/ contents by default (local workspace files)
# Only files explicitly whitelisted below will be tracked in git
*
# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# === Files tracked in git (shared across clones) ===
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
# This gitignore file itself
!.gitignore
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Issue data in JSONL format (the main data file)
# Legacy database files
db.sqlite
bd.db
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# Keep JSONL exports and config (source of truth for git)
!issues.jsonl
# Repository metadata (database name, JSONL filename)
!metadata.json
# Configuration template (sync branch, integrations)
!config.yaml
# Documentation for contributors
!README.md
!config.json

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{"id":"bd-3pd","ts":"2025-12-02T05:05:53.732197Z","by":"stevey","reason":"batch delete"}
{"id":"bd-ksc","ts":"2025-12-02T05:05:53.742343Z","by":"stevey","reason":"batch delete"}
{"id":"bd-360","ts":"2025-12-02T05:05:53.74642Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.1","ts":"2025-12-14T06:42:56.65452Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.6","ts":"2025-12-14T06:42:56.660775Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.8","ts":"2025-12-14T06:42:56.66501Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.9","ts":"2025-12-14T06:42:56.669731Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.10","ts":"2025-12-14T06:42:56.674952Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.12","ts":"2025-12-14T06:42:56.678754Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.13","ts":"2025-12-14T06:42:56.682961Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.1","ts":"2025-12-14T06:42:56.686774Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.2","ts":"2025-12-14T06:42:56.690962Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.3","ts":"2025-12-14T06:42:56.694789Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.4","ts":"2025-12-14T06:42:56.698915Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.5","ts":"2025-12-14T06:42:56.702767Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.1","ts":"2025-12-14T06:43:28.945078Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.6","ts":"2025-12-14T06:43:28.951574Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.8","ts":"2025-12-14T06:43:28.956558Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.9","ts":"2025-12-14T06:43:28.961447Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.10","ts":"2025-12-14T06:43:28.96553Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.12","ts":"2025-12-14T06:43:28.969449Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cb64c226.13","ts":"2025-12-14T06:43:28.973591Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.1","ts":"2025-12-14T06:43:28.977362Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.2","ts":"2025-12-14T06:43:28.981423Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.3","ts":"2025-12-14T06:43:28.985436Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.4","ts":"2025-12-14T06:43:28.989612Z","by":"stevey","reason":"batch delete"}
{"id":"bd-cbed9619.5","ts":"2025-12-14T06:43:28.993408Z","by":"stevey","reason":"batch delete"}

File diff suppressed because one or more lines are too long

View File

@@ -333,6 +333,9 @@ func TestCLI_Close(t *testing.T) {
if closed[0]["status"] != "closed" {
t.Errorf("Expected status 'closed', got: %v", closed[0]["status"])
}
if closed[0]["close_reason"] != "Done" {
t.Errorf("Expected close_reason 'Done', got: %v", closed[0]["close_reason"])
}
}
func TestCLI_DepAdd(t *testing.T) {

View File

@@ -86,5 +86,5 @@ func isWithinWorkspace(root, candidate string) bool {
if err != nil {
return false
}
return rel == "." || !(rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
}

View File

@@ -106,7 +106,9 @@ func getCurrentJSONLIDs(jsonlPath string) (map[string]bool, error) {
}
return nil, err
}
defer file.Close()
defer func() {
_ = file.Close()
}()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
@@ -118,12 +120,15 @@ func getCurrentJSONLIDs(jsonlPath string) (map[string]bool, error) {
}
var issue struct {
ID string `json:"id"`
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(line, &issue); err != nil {
continue
}
if issue.ID != "" {
// Skip tombstones - they represent migrated deletions and shouldn't
// be re-added to the deletions manifest (bd-in7q fix)
if issue.ID != "" && issue.Status != "tombstone" {
ids[issue.ID] = true
}
}
@@ -158,14 +163,16 @@ func looksLikeIssueID(id string) bool {
// Prefix should be alphanumeric (letters/numbers/underscores)
prefix := id[:dashIdx]
for _, c := range prefix {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
isValidPrefixChar := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
if !isValidPrefixChar {
return false
}
}
// Suffix should be alphanumeric (base36 hash or number), may contain dots for children
suffix := id[dashIdx+1:]
for _, c := range suffix {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
isValidSuffixChar := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.'
if !isValidSuffixChar {
return false
}
}

View File

@@ -0,0 +1,148 @@
package fix
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestGetCurrentJSONLIDs_SkipsTombstones(t *testing.T) {
// Setup: Create temp file with mix of normal issues and tombstones
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create a JSONL file with both normal issues and tombstones
issues := []*types.Issue{
{
ID: "bd-abc",
Title: "Normal issue",
Status: types.StatusOpen,
},
{
ID: "bd-def",
Title: "(deleted)",
Status: types.StatusTombstone,
DeletedBy: "test-user",
},
{
ID: "bd-ghi",
Title: "Another normal issue",
Status: types.StatusOpen,
},
{
ID: "bd-jkl",
Title: "(deleted)",
Status: types.StatusTombstone,
DeletedBy: "test-user",
},
}
file, err := os.Create(jsonlPath)
if err != nil {
t.Fatalf("Failed to create test JSONL file: %v", err)
}
encoder := json.NewEncoder(file)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
_ = file.Close()
t.Fatalf("Failed to write issue to JSONL: %v", err)
}
}
_ = file.Close()
// Call getCurrentJSONLIDs
ids, err := getCurrentJSONLIDs(jsonlPath)
if err != nil {
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
}
// Verify: Should only contain non-tombstone IDs
expectedIDs := map[string]bool{
"bd-abc": true,
"bd-ghi": true,
}
if len(ids) != len(expectedIDs) {
t.Errorf("Expected %d IDs, got %d. IDs: %v", len(expectedIDs), len(ids), ids)
}
for expectedID := range expectedIDs {
if !ids[expectedID] {
t.Errorf("Expected ID %s to be present", expectedID)
}
}
// Verify tombstones are NOT included
if ids["bd-def"] {
t.Error("Tombstone bd-def should not be included in current IDs")
}
if ids["bd-jkl"] {
t.Error("Tombstone bd-jkl should not be included in current IDs")
}
}
func TestGetCurrentJSONLIDs_HandlesEmptyFile(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Create empty file
if _, err := os.Create(jsonlPath); err != nil {
t.Fatalf("Failed to create empty file: %v", err)
}
ids, err := getCurrentJSONLIDs(jsonlPath)
if err != nil {
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
}
if len(ids) != 0 {
t.Errorf("Expected 0 IDs from empty file, got %d", len(ids))
}
}
func TestGetCurrentJSONLIDs_HandlesMissingFile(t *testing.T) {
tmpDir := t.TempDir()
nonexistentPath := filepath.Join(tmpDir, "nonexistent.jsonl")
ids, err := getCurrentJSONLIDs(nonexistentPath)
if err != nil {
t.Fatalf("getCurrentJSONLIDs should handle missing file gracefully: %v", err)
}
if len(ids) != 0 {
t.Errorf("Expected 0 IDs from missing file, got %d", len(ids))
}
}
func TestGetCurrentJSONLIDs_SkipsInvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
// Write mixed valid and invalid JSON lines
content := `{"id":"bd-valid","status":"open"}
invalid json line
{"id":"bd-another","status":"open"}
`
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
ids, err := getCurrentJSONLIDs(jsonlPath)
if err != nil {
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
}
if len(ids) != 2 {
t.Errorf("Expected 2 valid IDs, got %d. IDs: %v", len(ids), ids)
}
if !ids["bd-valid"] || !ids["bd-another"] {
t.Error("Expected to parse both valid issues despite invalid line in between")
}
}
// Note: Full integration test for HydrateDeletionsManifest would require git repo setup.
// The unit tests above verify the core fix (skipping tombstones in getCurrentJSONLIDs).
// Integration tests are handled in migrate_tombstones_test.go with full sync cycle.

108
cmd/bd/doctor/fix/docs.md Normal file
View File

@@ -0,0 +1,108 @@
# Noridoc: cmd/bd/doctor/fix
Path: @/cmd/bd/doctor/fix
### Overview
The `cmd/bd/doctor/fix` directory contains automated remediation functions for issues detected by the `bd doctor` command. Each module handles a specific category of issues (deletions manifest, database config, sync branch, etc.) and provides functions to automatically fix problems found in beads workspaces.
### How it fits into the larger codebase
- **Integration with Doctor Detection**: The `@/cmd/bd/doctor.go` command runs checks to identify workspace problems, then calls functions from this package when `--fix` flag is used. The doctor command is the orchestrator that determines which issues exist and which fixes to apply.
- **Dependency on Core Libraries**: The fix functions use core libraries like `@/internal/deletions` (for reading/writing deletion manifests), `@/internal/types` (for issue data structures), and git operations via `exec.Command`.
- **Data Persistence Points**: Each fix module directly modifies persistent workspace state: deletions manifest, database files, JSONL files, and git branch configuration. Changes are written to disk and persisted in the git repository.
- **Deletion Tracking Architecture**: The deletions manifest (`@/internal/deletions/deletions.go`) is an append-only log tracking issue deletions. The fix in `deletions.go` is critical to maintaining the integrity of this log by preventing tombstones from being incorrectly re-added to it after `bd migrate-tombstones` runs.
- **Tombstone System**: The fix works in concert with the tombstone system (`@/internal/types/types.go` - `Status == StatusTombstone`). Tombstones represent soft-deleted issues that contain deletion metadata. The fix prevents tombstones from being confused with actively deleted issues during deletion hydration.
### Core Implementation
**Deletions Manifest Hydration** (`deletions.go`):
1. **HydrateDeletionsManifest()** (lines 16-96):
- Entry point called by `bd doctor --fix` when "Deletions Manifest" issue is detected
- Compares current JSONL IDs (read from `issues.jsonl`) against historical IDs from git history
- Finds IDs that existed in history but are missing from current JSONL (legitimate deletions)
- Adds these missing IDs to the deletions manifest with author "bd-doctor-hydrate"
- Skips IDs already present in the existing deletions manifest to avoid duplicates
2. **getCurrentJSONLIDs()** (lines 98-135):
- Reads current `issues.jsonl` file line-by-line as JSON
- Parses each line to extract ID and Status fields
- **CRITICAL FIX (bd-in7q)**: Skips issues with `Status == "tombstone"` (lines 127-131)
- Returns a set of "currently active" issue IDs
- Gracefully handles missing files (returns empty set) and malformed JSON lines (skips them)
- This is where the bd-in7q fix is implemented - tombstones are not considered "currently active" and won't be flagged as deleted
3. **getHistoricalJSONLIDs()** (lines 137-148):
- Delegates to `getHistoricalIDsViaDiff()` to extract all IDs ever present in JSONL from git history
- Uses git log to find all commits that modified the JSONL file
4. **getHistoricalIDsViaDiff()** (lines 178-232):
- Walks git history commit-by-commit (memory efficient)
- For each commit touching the JSONL file, parses JSON to extract IDs
- Uses `looksLikeIssueID()` validation to avoid false positives from JSON containing ID-like strings
- Returns complete set of all IDs ever present in the repo history
5. **looksLikeIssueID()** (lines 150-176):
- Validates that a string matches the issue ID format: `prefix-suffix`
- Prefix must be alphanumeric with underscores, suffix must be base36 hash or number with optional dots for child issues
- Used to filter out false positives when parsing JSON
**Test Coverage** (`deletions_test.go`):
The test file covers edge cases and validates the bd-in7q fix:
- **TestGetCurrentJSONLIDs_SkipsTombstones**: Core fix validation - verifies tombstones are excluded from current IDs
- **TestGetCurrentJSONLIDs_HandlesEmptyFile**: Graceful handling of empty JSONL files
- **TestGetCurrentJSONLIDs_HandlesMissingFile**: Graceful handling when JSONL doesn't exist
- **TestGetCurrentJSONLIDs_SkipsInvalidJSON**: Malformed JSON lines are skipped without failing
### Things to Know
**The bd-in7q Bug and Fix**:
The bug occurred because `bd migrate-tombstones` converts deletion records from the legacy `deletions.jsonl` file into inline tombstone entries in `issues.jsonl`. Without the fix, the sequence would be:
1. User runs `bd migrate-tombstones` → creates tombstones in JSONL with `status: "tombstone"`
2. User runs `bd sync` → triggers `bd doctor hydrate`
3. `getCurrentJSONLIDs()` was reading ALL issues including tombstones
4. Comparison logic sees tombstones are no longer in git history commit 0 (before migration)
5. They're flagged as "deleted" and re-added to deletions manifest with author "bd-doctor-hydrate"
6. Next sync applies these deletion records, marking issues as deleted in the database
7. Result: thousands of false deletion records corrupt the manifest and database state
The fix simply filters out `Status == "tombstone"` issues in `getCurrentJSONLIDs()` (line 129). This ensures tombstones (which represent already-recorded deletions) never participate in deletion detection. They're semantically invisible to the deletion tracking system.
**Why Tombstones Exist**:
`@/internal/types/types.go` defines `StatusTombstone` as part of the system (bd-vw8). Tombstones are soft-deleted issues that retain all metadata (ID, DeletedBy, DeletedAt, DeleteReason) for audit trails and conflict resolution. They differ from entries in the deletions manifest, which are just an ID + deletion metadata without the original issue content.
**Append-Only Nature of Deletions Manifest**:
The deletions manifest (`@/internal/deletions/deletions.go`) is append-only. When a duplicate deletion is added, the last write wins (line 81 in deletions.go). This design assumes deletions are only recorded once, which the fix preserves by skipping tombstones.
**Missing File Handling**:
The `getCurrentJSONLIDs()` function returns an empty set when the JSONL file doesn't exist (lines 104-105). This is intentional - it allows hydration to work on repos that have never had issues.json yet. Only `getHistoricalIDsViaDiff()` will find historical IDs from git.
**ID Format Validation**:
The `looksLikeIssueID()` function validates format strictly (lines 150-176). This prevents parsing errors from embedded JSON with accidental ID-like strings. Example: if issue description contains `"id":"some-text"`, it won't be treated as an issue ID.
**Integration with Migrate Tombstones**:
The `@/cmd/bd/migrate_tombstones.go` command creates tombstones using `convertDeletionRecordToTombstone()` (lines 268-284). These tombstones have `Status == types.StatusTombstone`. The fix works because migrate-tombstones sets this status correctly (verified by `TestMigrateTombstones_TombstonesAreValid()` in migrate_tombstones_test.go).
**State Machine for Deleted Issues**:
There are now two ways an issue can be marked as deleted:
1. **Database state**: Issue has `status = "tombstone"` in the database (from `@/internal/storage/sqlite`)
2. **Manifest state**: Issue ID appears in `deletions.jsonl` (from `@/internal/deletions`)
The deletion hydration logic treats deletions manifest as the source of truth for what SHOULD be deleted, then applies those deletions to the database. The fix ensures the manifest only contains legitimate deletions, not tombstones that were migrated from the manifest.
Created and maintained by Nori.

View File

@@ -224,3 +224,69 @@ func TestConvertDeletionRecordToTombstone(t *testing.T) {
t.Errorf("Expected empty OriginalType, got %s", tombstone.OriginalType)
}
}
// TestMigrateTombstones_TombstonesAreValid verifies that migrated tombstones
// have the tombstone status set, so they won't be re-added to deletions manifest (bd-in7q fix)
func TestMigrateTombstones_TombstonesAreValid(t *testing.T) {
// Setup: create temp .beads directory
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
// Create deletions.jsonl with some entries
deletionsPath := deletions.DefaultPath(beadsDir)
deleteTime := time.Now().Add(-24 * time.Hour)
records := []deletions.DeletionRecord{
{ID: "test-abc", Timestamp: deleteTime, Actor: "alice", Reason: "duplicate"},
}
for _, record := range records {
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
t.Fatalf("Failed to write deletion: %v", err)
}
}
// Create empty issues.jsonl
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(issuesPath, []byte{}, 0600); err != nil {
t.Fatalf("Failed to create issues.jsonl: %v", err)
}
// Load deletions
loadResult, err := deletions.LoadDeletions(deletionsPath)
if err != nil {
t.Fatalf("LoadDeletions failed: %v", err)
}
// Convert to tombstones (simulating what migrate-tombstones does)
var tombstones []*types.Issue
for _, record := range loadResult.Records {
ts := convertDeletionRecordToTombstone(record)
// CRITICAL: Tombstones must have status "tombstone"
// so they won't be re-added to deletions manifest on next sync (bd-in7q)
if ts.Status != types.StatusTombstone {
t.Errorf("Converted tombstone must have status 'tombstone', got %s", ts.Status)
}
tombstones = append(tombstones, ts)
}
// Verify tombstone is valid
if len(tombstones) != 1 {
t.Fatalf("Expected 1 tombstone, got %d", len(tombstones))
}
ts := tombstones[0]
// These fields are critical for the doctor fix to work correctly
if ts.ID != "test-abc" {
t.Errorf("Expected ID test-abc, got %s", ts.ID)
}
if ts.Status != types.StatusTombstone {
t.Errorf("Expected status tombstone, got %s", ts.Status)
}
if ts.DeletedBy != "alice" {
t.Errorf("Expected DeletedBy 'alice', got %s", ts.DeletedBy)
}
}

View File

@@ -0,0 +1,159 @@
# Context Engineering for beads-mcp
## Overview
This document describes the context engineering optimizations added to beads-mcp to reduce context window usage by ~80-90% while maintaining full functionality.
## The Problem
MCP servers load all tool schemas at startup, consuming significant context:
- **Before:** ~10-50k tokens for full beads tool schemas
- **After:** ~2-5k tokens with lazy loading and compaction
For coding agents operating in limited context windows (100k-200k tokens), this overhead leaves less room for:
- Code files and diffs
- Conversation history
- Task planning and reasoning
## Solutions Implemented
### 1. Lazy Tool Schema Loading
Instead of loading all tool schemas upfront, agents can discover tools on-demand:
```python
# Step 1: Discover available tools (lightweight - ~500 bytes)
discover_tools()
# Returns: { "tools": { "ready": "Find ready tasks", ... }, "count": 15 }
# Step 2: Get details for specific tool (~300 bytes each)
get_tool_info("ready")
# Returns: { "name": "ready", "parameters": {...}, "example": "..." }
```
**Savings:** ~95% reduction in initial schema overhead
### 2. Minimal Issue Models
List operations now return `IssueMinimal` instead of full `Issue`:
```python
# IssueMinimal (~80 bytes per issue)
{
"id": "bd-a1b2",
"title": "Fix auth bug",
"status": "open",
"priority": 1,
"issue_type": "bug",
"assignee": "alice",
"labels": ["backend"],
"dependency_count": 2,
"dependent_count": 0
}
# vs Full Issue (~400 bytes per issue)
{
"id": "bd-a1b2",
"title": "Fix auth bug",
"description": "Long description...",
"design": "Design notes...",
"acceptance_criteria": "...",
"notes": "...",
"status": "open",
"priority": 1,
"issue_type": "bug",
"created_at": "2024-01-01T...",
"updated_at": "2024-01-02T...",
"closed_at": null,
"assignee": "alice",
"labels": ["backend"],
"dependencies": [...],
"dependents": [...],
...
}
```
**Savings:** ~80% reduction per issue in list views
### 3. Result Compaction
When results exceed threshold (20 issues), returns preview + metadata:
```python
# Request: list(status="open")
# Response when >20 results:
{
"compacted": true,
"total_count": 47,
"preview": [/* first 5 issues */],
"preview_count": 5,
"hint": "Use show(issue_id) for full details or add filters"
}
```
**Savings:** Prevents unbounded context growth from large queries
## Usage Patterns
### Efficient Workflow (Recommended)
```python
# 1. Set context once
set_context(workspace_root="/path/to/project")
# 2. Get ready work (minimal format)
issues = ready(limit=10, priority=1)
# 3. Pick an issue and get full details only when needed
full_issue = show(issue_id="bd-a1b2")
# 4. Do work...
# 5. Close when done
close(issue_id="bd-a1b2", reason="Fixed in PR #123")
```
### Tool Discovery Workflow
```python
# First time using beads? Discover tools efficiently:
tools = discover_tools()
# → {"tools": {"ready": "...", "list": "...", ...}, "count": 15}
# Need to know how to use a specific tool?
info = get_tool_info("create")
# → {"parameters": {...}, "example": "create(title='...', ...)"}
```
## Configuration
Compaction settings in `server.py`:
```python
COMPACTION_THRESHOLD = 20 # Compact results with more than N issues
PREVIEW_COUNT = 5 # Show N issues in preview
```
## Comparison
| Scenario | Before | After | Savings |
|----------|--------|-------|---------|
| Tool schemas (all) | ~15,000 bytes | ~500 bytes | 97% |
| List 50 issues | ~20,000 bytes | ~4,000 bytes | 80% |
| Ready work (10) | ~4,000 bytes | ~800 bytes | 80% |
| Single show() | ~400 bytes | ~400 bytes | 0% (full details) |
## Design Principles
1. **Lazy Loading**: Only fetch what you need, when you need it
2. **Minimal by Default**: List views use lightweight models
3. **Full Details On-Demand**: Use `show()` for complete information
4. **Graceful Degradation**: Large results auto-compact with hints
5. **Backward Compatible**: Existing workflows continue to work
## Credits
Inspired by:
- [MCP Bridge](https://github.com/mahawi1992/mwilliams_mcpbridge) - Context engineering for MCP servers
- [Manus Context Engineering](https://rlancemartin.github.io/2025/10/15/manus/) - Compaction and offloading patterns
- [Anthropic's Context Engineering Guide](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)

View File

@@ -1,7 +1,7 @@
"""Pydantic models for beads issue tracker types."""
from datetime import datetime
from typing import Literal
from typing import Literal, Any
from pydantic import BaseModel, Field, field_validator
@@ -11,6 +11,53 @@ IssueType = Literal["bug", "feature", "task", "epic", "chore"]
DependencyType = Literal["blocks", "related", "parent-child", "discovered-from"]
# =============================================================================
# CONTEXT ENGINEERING: Minimal Models for List Views
# =============================================================================
# These lightweight models reduce context window usage by ~80% for list operations.
# Use full Issue model only when detailed information is needed (show command).
class IssueMinimal(BaseModel):
"""Minimal issue model for list views (~80% smaller than full Issue).
Use this for ready_work, list_issues, and other bulk operations.
For full details including dependencies, use Issue model via show().
"""
id: str
title: str
status: IssueStatus
priority: int = Field(ge=0, le=4)
issue_type: IssueType
assignee: str | None = None
labels: list[str] = Field(default_factory=list)
dependency_count: int = 0
dependent_count: int = 0
@field_validator("priority")
@classmethod
def validate_priority(cls, v: int) -> int:
if not 0 <= v <= 4:
raise ValueError("Priority must be between 0 and 4")
return v
class CompactedResult(BaseModel):
"""Result container for compacted list responses.
When results exceed threshold, returns preview + metadata instead of full data.
This prevents context window overflow for large issue lists.
"""
compacted: bool = True
total_count: int
preview: list[IssueMinimal]
preview_count: int
hint: str = "Use show(issue_id) for full issue details"
# =============================================================================
# ORIGINAL MODELS (unchanged for backward compatibility)
# =============================================================================
class IssueBase(BaseModel):
"""Base issue model with shared fields."""

View File

@@ -1,4 +1,14 @@
"""FastMCP server for beads issue tracker."""
"""FastMCP server for beads issue tracker.
Context Engineering Optimizations (v0.24.0):
- Lazy tool schema loading via discover_tools() and get_tool_info()
- Minimal issue models for list views (~80% context reduction)
- Result compaction for large queries (>20 issues)
- On-demand full details via show() command
These optimizations reduce context window usage from ~10-50k tokens to ~2-5k tokens,
enabling more efficient agent operation without sacrificing functionality.
"""
import asyncio
import atexit
@@ -14,7 +24,16 @@ from typing import Any, Awaitable, Callable, TypeVar
from fastmcp import FastMCP
from beads_mcp.models import BlockedIssue, DependencyType, Issue, IssueStatus, IssueType, Stats
from beads_mcp.models import (
BlockedIssue,
CompactedResult,
DependencyType,
Issue,
IssueMinimal,
IssueStatus,
IssueType,
Stats,
)
from beads_mcp.tools import (
beads_add_dependency,
beads_blocked,
@@ -54,6 +73,12 @@ _cleanup_done = False
# os.environ doesn't persist across MCP requests, so we need module-level storage
_workspace_context: dict[str, str] = {}
# =============================================================================
# CONTEXT ENGINEERING: Compaction Settings
# =============================================================================
COMPACTION_THRESHOLD = 20 # Compact results with more than 20 issues
PREVIEW_COUNT = 5 # Show first 5 issues in preview
# Create FastMCP server
mcp = FastMCP(
name="Beads",
@@ -61,6 +86,9 @@ mcp = FastMCP(
We track work in Beads (bd) instead of Markdown.
Check the resource beads://quickstart to see how.
CONTEXT OPTIMIZATION: Use discover_tools() to see available tools (names only),
then get_tool_info(tool_name) for specific tool details. This saves context.
IMPORTANT: Call set_context with your workspace root before any write operations.
""",
)
@@ -239,6 +267,193 @@ async def get_quickstart() -> str:
return await beads_quickstart()
# =============================================================================
# CONTEXT ENGINEERING: Tool Discovery (Lazy Schema Loading)
# =============================================================================
# These tools enable agents to discover available tools without loading full schemas.
# This reduces initial context from ~10-50k tokens to ~500 bytes.
# Tool metadata for discovery (lightweight - just names and brief descriptions)
_TOOL_CATALOG = {
"ready": "Find tasks ready to work on (no blockers)",
"list": "List issues with filters (status, priority, type)",
"show": "Show full details for a specific issue",
"create": "Create a new issue (bug, feature, task, epic)",
"update": "Update issue status, priority, or assignee",
"close": "Close/complete an issue",
"reopen": "Reopen closed issues",
"dep": "Add dependency between issues",
"stats": "Get issue statistics",
"blocked": "Show blocked issues and what blocks them",
"init": "Initialize beads in a directory",
"set_context": "Set workspace root for operations",
"where_am_i": "Show current workspace context",
"discover_tools": "List available tools (names only)",
"get_tool_info": "Get detailed info for a specific tool",
}
@mcp.tool(
name="discover_tools",
description="List available beads tools (names and brief descriptions only). Use get_tool_info() for full details.",
)
async def discover_tools() -> dict[str, Any]:
"""Discover available beads tools without loading full schemas.
Returns lightweight tool catalog to minimize context usage.
Use get_tool_info(tool_name) for full parameter details.
Context savings: ~500 bytes vs ~10-50k for full schemas.
"""
return {
"tools": _TOOL_CATALOG,
"count": len(_TOOL_CATALOG),
"hint": "Use get_tool_info('tool_name') for full parameters and usage"
}
@mcp.tool(
name="get_tool_info",
description="Get detailed information about a specific beads tool including parameters.",
)
async def get_tool_info(tool_name: str) -> dict[str, Any]:
"""Get detailed info for a specific tool.
Args:
tool_name: Name of the tool to get info for
Returns:
Full tool details including parameters and usage examples
"""
tool_details = {
"ready": {
"name": "ready",
"description": "Find tasks with no blockers, ready to work on",
"parameters": {
"limit": "int (1-100, default 10) - Max issues to return",
"priority": "int (0-4, optional) - Filter by priority",
"assignee": "str (optional) - Filter by assignee",
"workspace_root": "str (optional) - Workspace path"
},
"returns": "List of ready issues (minimal format for context efficiency)",
"example": "ready(limit=5, priority=1)"
},
"list": {
"name": "list",
"description": "List all issues with optional filters",
"parameters": {
"status": "open|in_progress|blocked|closed (optional)",
"priority": "int 0-4 (optional)",
"issue_type": "bug|feature|task|epic|chore (optional)",
"assignee": "str (optional)",
"limit": "int (1-100, default 20)",
"workspace_root": "str (optional)"
},
"returns": "List of issues (compacted if >20 results)",
"example": "list(status='open', priority=1, limit=10)"
},
"show": {
"name": "show",
"description": "Show full details for a specific issue including dependencies",
"parameters": {
"issue_id": "str (required) - e.g., 'bd-a1b2'",
"workspace_root": "str (optional)"
},
"returns": "Full Issue object with dependencies and dependents",
"example": "show(issue_id='bd-a1b2')"
},
"create": {
"name": "create",
"description": "Create a new issue",
"parameters": {
"title": "str (required)",
"description": "str (default '')",
"priority": "int 0-4 (default 2)",
"issue_type": "bug|feature|task|epic|chore (default task)",
"assignee": "str (optional)",
"labels": "list[str] (optional)",
"deps": "list[str] (optional) - dependency IDs",
"workspace_root": "str (optional)"
},
"returns": "Created Issue object",
"example": "create(title='Fix auth bug', priority=1, issue_type='bug')"
},
"update": {
"name": "update",
"description": "Update an existing issue",
"parameters": {
"issue_id": "str (required)",
"status": "open|in_progress|blocked|closed (optional)",
"priority": "int 0-4 (optional)",
"assignee": "str (optional)",
"title": "str (optional)",
"description": "str (optional)",
"workspace_root": "str (optional)"
},
"returns": "Updated Issue object",
"example": "update(issue_id='bd-a1b2', status='in_progress')"
},
"close": {
"name": "close",
"description": "Close/complete an issue",
"parameters": {
"issue_id": "str (required)",
"reason": "str (default 'Completed')",
"workspace_root": "str (optional)"
},
"returns": "List of closed issues",
"example": "close(issue_id='bd-a1b2', reason='Fixed in PR #123')"
},
"reopen": {
"name": "reopen",
"description": "Reopen one or more closed issues",
"parameters": {
"issue_ids": "list[str] (required)",
"reason": "str (optional)",
"workspace_root": "str (optional)"
},
"returns": "List of reopened issues",
"example": "reopen(issue_ids=['bd-a1b2'], reason='Need more work')"
},
"dep": {
"name": "dep",
"description": "Add dependency between issues",
"parameters": {
"issue_id": "str (required) - Issue that has the dependency",
"depends_on_id": "str (required) - Issue it depends on",
"dep_type": "blocks|related|parent-child|discovered-from (default blocks)",
"workspace_root": "str (optional)"
},
"returns": "Confirmation message",
"example": "dep(issue_id='bd-f1a2', depends_on_id='bd-a1b2', dep_type='blocks')"
},
"stats": {
"name": "stats",
"description": "Get issue statistics",
"parameters": {"workspace_root": "str (optional)"},
"returns": "Stats object with counts and metrics",
"example": "stats()"
},
"blocked": {
"name": "blocked",
"description": "Show blocked issues and what blocks them",
"parameters": {"workspace_root": "str (optional)"},
"returns": "List of blocked issues with blocker info",
"example": "blocked()"
},
}
if tool_name not in tool_details:
available = list(tool_details.keys())
return {
"error": f"Unknown tool: {tool_name}",
"available_tools": available,
"hint": "Use discover_tools() to see all available tools"
}
return tool_details[tool_name]
# Context management tools
@mcp.tool(
name="set_context",
@@ -338,29 +553,61 @@ async def where_am_i(workspace_root: str | None = None) -> str:
# Register all tools
@mcp.tool(name="ready", description="Find tasks that have no blockers and are ready to be worked on.")
# =============================================================================
# CONTEXT ENGINEERING: Optimized List Tools with Compaction
# =============================================================================
def _to_minimal(issue: Issue) -> IssueMinimal:
"""Convert full Issue to minimal format for context efficiency."""
return IssueMinimal(
id=issue.id,
title=issue.title,
status=issue.status,
priority=issue.priority,
issue_type=issue.issue_type,
assignee=issue.assignee,
labels=issue.labels,
dependency_count=issue.dependency_count,
dependent_count=issue.dependent_count,
)
@mcp.tool(name="ready", description="Find tasks that have no blockers and are ready to be worked on. Returns minimal format for context efficiency.")
@with_workspace
async def ready_work(
limit: int = 10,
priority: int | None = None,
assignee: str | None = None,
workspace_root: str | None = None,
) -> list[Issue]:
"""Find issues with no blocking dependencies that are ready to work on."""
) -> list[IssueMinimal] | CompactedResult:
"""Find issues with no blocking dependencies that are ready to work on.
Returns minimal issue format to reduce context usage by ~80%.
Use show(issue_id) for full details including dependencies.
If results exceed threshold, returns compacted preview.
"""
issues = await beads_ready_work(limit=limit, priority=priority, assignee=assignee)
# Strip dependencies/dependents to reduce payload size
# Use show() for full details
for issue in issues:
issue.dependencies = []
issue.dependents = []
# Convert to minimal format
minimal_issues = [_to_minimal(issue) for issue in issues]
return issues
# Apply compaction if over threshold
if len(minimal_issues) > COMPACTION_THRESHOLD:
return CompactedResult(
compacted=True,
total_count=len(minimal_issues),
preview=minimal_issues[:PREVIEW_COUNT],
preview_count=PREVIEW_COUNT,
hint=f"Showing {PREVIEW_COUNT} of {len(minimal_issues)} ready issues. Use show(issue_id) for full details."
)
return minimal_issues
@mcp.tool(
name="list",
description="List all issues with optional filters (status, priority, type, assignee).",
description="List all issues with optional filters (status, priority, type, assignee). Returns minimal format for context efficiency.",
)
@with_workspace
async def list_issues(
@@ -368,10 +615,16 @@ async def list_issues(
priority: int | None = None,
issue_type: IssueType | None = None,
assignee: str | None = None,
limit: int = 20, # Reduced from 50 to avoid MCP buffer overflow
limit: int = 20,
workspace_root: str | None = None,
) -> list[Issue]:
"""List all issues with optional filters."""
) -> list[IssueMinimal] | CompactedResult:
"""List all issues with optional filters.
Returns minimal issue format to reduce context usage by ~80%.
Use show(issue_id) for full details including dependencies.
If results exceed threshold, returns compacted preview.
"""
issues = await beads_list_issues(
status=status,
priority=priority,
@@ -380,13 +633,20 @@ async def list_issues(
limit=limit,
)
# Strip dependencies/dependents to reduce payload size
# Use show() for full details
for issue in issues:
issue.dependencies = []
issue.dependents = []
# Convert to minimal format
minimal_issues = [_to_minimal(issue) for issue in issues]
return issues
# Apply compaction if over threshold
if len(minimal_issues) > COMPACTION_THRESHOLD:
return CompactedResult(
compacted=True,
total_count=len(minimal_issues),
preview=minimal_issues[:PREVIEW_COUNT],
preview_count=PREVIEW_COUNT,
hint=f"Showing {PREVIEW_COUNT} of {len(minimal_issues)} issues. Use show(issue_id) for full details or add filters to narrow results."
)
return minimal_issues
@mcp.tool(

View File

@@ -500,10 +500,13 @@ func manageClosedAt(oldIssue *types.Issue, updates map[string]interface{}, setCl
setClauses = append(setClauses, "closed_at = ?")
args = append(args, now)
} else if oldIssue.Status == types.StatusClosed {
// Changing from closed to something else: clear closed_at
// Changing from closed to something else: clear closed_at and close_reason
updates["closed_at"] = nil
setClauses = append(setClauses, "closed_at = ?")
args = append(args, nil)
updates["close_reason"] = ""
setClauses = append(setClauses, "close_reason = ?")
args = append(args, "")
}
return setClauses, args
@@ -806,10 +809,14 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string
}
defer func() { _ = tx.Rollback() }()
// NOTE: close_reason is stored in two places:
// 1. issues.close_reason - for direct queries (bd show --json, exports)
// 2. events.comment - for audit history (when was it closed, by whom)
// Keep both in sync. If refactoring, consider deriving one from the other.
result, err := tx.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
WHERE id = ?
`, types.StatusClosed, now, now, id)
`, types.StatusClosed, now, now, reason, id)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}

View File

@@ -706,6 +706,10 @@ func TestCloseIssue(t *testing.T) {
if closed.ClosedAt == nil {
t.Error("ClosedAt should be set")
}
if closed.CloseReason != "Done" {
t.Errorf("CloseReason not set: got %q, want %q", closed.CloseReason, "Done")
}
}
func TestClosedAtInvariant(t *testing.T) {
@@ -766,7 +770,7 @@ func TestClosedAtInvariant(t *testing.T) {
t.Fatalf("CloseIssue failed: %v", err)
}
// Verify it's closed with closed_at set
// Verify it's closed with closed_at and close_reason set
closed, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
@@ -774,6 +778,9 @@ func TestClosedAtInvariant(t *testing.T) {
if closed.ClosedAt == nil {
t.Fatal("ClosedAt should be set after closing")
}
if closed.CloseReason != "Done" {
t.Errorf("CloseReason should be 'Done', got %q", closed.CloseReason)
}
// Reopen the issue
updates := map[string]interface{}{
@@ -784,7 +791,7 @@ func TestClosedAtInvariant(t *testing.T) {
t.Fatalf("UpdateIssue failed: %v", err)
}
// Verify closed_at was cleared
// Verify closed_at and close_reason were cleared
reopened, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
@@ -795,6 +802,9 @@ func TestClosedAtInvariant(t *testing.T) {
if reopened.ClosedAt != nil {
t.Error("ClosedAt should be cleared when reopening issue")
}
if reopened.CloseReason != "" {
t.Errorf("CloseReason should be cleared when reopening issue, got %q", reopened.CloseReason)
}
})
t.Run("CreateIssue rejects closed issue without closed_at", func(t *testing.T) {

View File

@@ -462,13 +462,14 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) {
}
// CloseIssue closes an issue within the transaction.
// NOTE: close_reason is stored in both issues table and events table - see SQLiteStorage.CloseIssue.
func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
now := time.Now()
result, err := t.conn.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
WHERE id = ?
`, types.StatusClosed, now, now, id)
`, types.StatusClosed, now, now, reason, id)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}

View File

@@ -268,6 +268,9 @@ func TestTransactionCloseIssue(t *testing.T) {
if closed.Status != types.StatusClosed {
t.Errorf("expected status 'closed', got %q", closed.Status)
}
if closed.CloseReason != "Done" {
t.Errorf("expected close_reason 'Done', got %q", closed.CloseReason)
}
}
// TestTransactionDeleteIssue tests deleting an issue within a transaction.