diff --git a/cmd/Assist/main.go b/cmd/Assist/main.go index 16636ae..e2935bc 100644 --- a/cmd/Assist/main.go +++ b/cmd/Assist/main.go @@ -52,7 +52,8 @@ type SessionInfo struct { } var ( - fs *client.Fsys + fs *client.Fsys + beadsFs *client.Fsys // Track window IDs for prompt windows (client-side state) promptWindows = make(map[string]int) // session ID -> window ID promptWindowsMu sync.RWMutex // protects promptWindows map @@ -97,6 +98,12 @@ func main() { } } } + + // Connect to 9beads (optional - beads features disabled if not running) + beadsFs, err = connectToBeads() + if err != nil { + logging.Logger().Info("9beads not running, beads features disabled") + } if fs != nil { defer fs.Close() } @@ -360,10 +367,22 @@ func connectToServer() (*client.Fsys, error) { return client.MountService("anvillm") } +func connectToBeads() (*client.Fsys, error) { + ns := client.Namespace() + if ns == "" { + return nil, fmt.Errorf("no namespace") + } + return client.MountService("beads") +} + func isConnected() bool { return fs != nil } +func isBeadsConnected() bool { + return beadsFs != nil +} + func createSession(backend, cwd string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv (use Daemon command to start server)") @@ -425,7 +444,7 @@ func getMountForSession(sessionID string) string { if err != nil || sess.Cwd == "" { return "" } - data, err := readFile("beads/mtab") + data, err := readBeadsFile("mtab") if err != nil { return "" } @@ -622,6 +641,44 @@ func writeFile(path string, data []byte) error { return err } +func readBeadsFile(path string) ([]byte, error) { + if !isBeadsConnected() { + return nil, fmt.Errorf("not connected to 9beads") + } + fid, err := beadsFs.Open(path, plan9.OREAD) + if err != nil { + return nil, err + } + defer fid.Close() + + var buf []byte + tmp := make([]byte, 8192) + for { + n, err := fid.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + return buf, nil +} + +func writeBeadsFile(path string, data []byte) error { + if !isBeadsConnected() { + return fmt.Errorf("not connected to 9beads") + } + fid, err := beadsFs.Open(path, plan9.OWRITE) + if err != nil { + return err + } + defer fid.Close() + + _, err = fid.Write(data) + return err +} + func refreshList(w *acme.Win) { var buf strings.Builder buf.WriteString("Backends: [Kiro] [Claude] [Ollama]\n\n") @@ -2245,7 +2302,7 @@ func handleTasksWindow(w *acme.Win) { fmt.Fprintf(os.Stderr, "Error unmounting: %v\n", err) } else { if windowMount == name { - data, _ := readFile("beads/mtab") + data, _ := readBeadsFile("mtab") lines := strings.Split(strings.TrimSpace(string(data)), "\n") found := false for _, line := range lines { @@ -2271,7 +2328,7 @@ func handleTasksWindow(w *acme.Win) { case "Select": name := strings.TrimSpace(arg) if name == "" { - data, err := readFile("beads/mtab") + data, err := readBeadsFile("mtab") if err != nil { fmt.Fprintf(os.Stderr, "Error reading mtab: %v\n", err) } else { @@ -2279,7 +2336,7 @@ func handleTasksWindow(w *acme.Win) { fmt.Fprintf(os.Stderr, "Usage: select mount name, then Select\n") } } else { - data, err := readFile("beads/mtab") + data, err := readBeadsFile("mtab") if err != nil { fmt.Fprintf(os.Stderr, "Error reading mtab: %v\n", err) } else { @@ -2382,21 +2439,21 @@ func listBeadsWithFilter(filter string, mount string) ([]Bead, error) { switch filter { case "deferred": if mount == "" { - endpoint = "beads/deferred" + endpoint = "deferred" } else { - endpoint = filepath.Join("beads", mount, "deferred") + endpoint = filepath.Join(mount, "deferred") } case "ready": if mount == "" { - endpoint = "beads/ready" + endpoint = "ready" } else { - endpoint = filepath.Join("beads", mount, "ready") + endpoint = filepath.Join(mount, "ready") } default: - endpoint = filepath.Join("beads", mount, "list") + endpoint = filepath.Join(mount, "list") } - data, err := readFile(endpoint) + data, err := readBeadsFile(endpoint) if err != nil { return nil, err } @@ -2519,7 +2576,7 @@ func createBeadFromMarkdown(content, parentID string, mount string) error { cmd = fmt.Sprintf("%s blockers=%s", cmd, blockers) } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } func openViewBeadWindow(beadID string, mount string) error { @@ -2689,7 +2746,7 @@ func handleCommentWindow(w *acme.Win, beadID string, mount string) { continue } cmd := fmt.Sprintf("comment %s '%s'", beadID, text) - if err := writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)); err != nil { + if err := writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)); err != nil { fmt.Fprintf(os.Stderr, "Error adding comment: %v\n", err) } else { w.Addr(",") @@ -2724,7 +2781,7 @@ func handleCommentsWindow(w *acme.Win, beadID string, mount string) { } func refreshCommentsWindow(w *acme.Win, beadID string, mount string) { - data, err := readFile(filepath.Join("beads", mount, beadID, "comments")) + data, err := readBeadsFile(filepath.Join( mount, beadID, "comments")) if err != nil { w.Addr(",") w.Write("data", []byte(fmt.Sprintf("Error reading comments: %v\n", err))) @@ -2766,7 +2823,7 @@ func getBead(beadID string, mount string) (*Bead, error) { return nil, fmt.Errorf("not connected to anvilsrv") } - data, err := readFile(filepath.Join("beads", mount, beadID, "json")) + data, err := readBeadsFile(filepath.Join( mount, beadID, "json")) if err != nil { return nil, err } @@ -2786,7 +2843,7 @@ func addBlocksDep(blockerID, blockedID string, mount string) error { // dep means parent blocks child // So "blockerID blocks blockedID" means blockedID depends on blockerID cmd := fmt.Sprintf("dep %s %s", blockedID, blockerID) - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } func removeBlocksDep(beadID, blockerID string, mount string) error { @@ -2794,7 +2851,7 @@ func removeBlocksDep(beadID, blockerID string, mount string) error { return fmt.Errorf("not connected to anvilsrv") } cmd := fmt.Sprintf("undep %s %s", beadID, blockerID) - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } func deleteBead(beadID string, mount string) error { @@ -2802,49 +2859,49 @@ func deleteBead(beadID string, mount string) error { return fmt.Errorf("not connected to anvilsrv") } cmd := fmt.Sprintf("delete %s", beadID) - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } func claimBeadAsUser(beadID string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("claim %s user", beadID))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("claim %s user", beadID))) } func unclaimBead(beadID string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("unclaim %s", beadID))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("unclaim %s", beadID))) } func completeBead(beadID string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("complete %s", beadID))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("complete %s", beadID))) } func failBead(beadID, reason string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("fail %s %s", beadID, reason))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("fail %s %s", beadID, reason))) } func openBead(beadID string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("open %s", beadID))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("open %s", beadID))) } func deferBead(beadID string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("defer %s", beadID))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("defer %s", beadID))) } func updateBead(beadID, content string, origBlockers []string, mount string) error { @@ -2903,11 +2960,11 @@ func updateBead(beadID, content string, origBlockers []string, mount string) err // Update title and description if title != "" { cmd := fmt.Sprintf("update %s title '%s'", beadID, shellEscape(title)) - writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } if description != "" { cmd := fmt.Sprintf("update %s description '%s'", beadID, shellEscape(description)) - writeFile(filepath.Join("beads", mount, "ctl"), []byte(cmd)) + writeBeadsFile(filepath.Join( mount, "ctl"), []byte(cmd)) } // Update blockers @@ -2933,16 +2990,16 @@ func updateBead(beadID, content string, origBlockers []string, mount string) err // Toggle requires_approval label if requiresApproval == 1 { - writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("label %s requires_approval", beadID))) + writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("label %s requires_approval", beadID))) } else if requiresApproval == 0 { - writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("unlabel %s requires_approval", beadID))) + writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("unlabel %s requires_approval", beadID))) } // Toggle requires_review label if requiresReview == 1 { - writeFile(filepath.Join("beads", "", "ctl"), []byte(fmt.Sprintf("label %s requires_review", beadID))) + writeBeadsFile(filepath.Join( "", "ctl"), []byte(fmt.Sprintf("label %s requires_review", beadID))) } else if requiresReview == 0 { - writeFile(filepath.Join("beads", "", "ctl"), []byte(fmt.Sprintf("unlabel %s requires_review", beadID))) + writeBeadsFile(filepath.Join( "", "ctl"), []byte(fmt.Sprintf("unlabel %s requires_review", beadID))) } return nil @@ -2952,7 +3009,7 @@ func initBeads(prefix string, mount string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile(filepath.Join("beads", mount, "ctl"), []byte(fmt.Sprintf("init %s", prefix))) + return writeBeadsFile(filepath.Join( mount, "ctl"), []byte(fmt.Sprintf("init %s", prefix))) } // mountProject mounts cwd via mount_beads.sh mcptool and returns the generated mount name. @@ -2978,5 +3035,5 @@ func umountProject(name string) error { if !isConnected() { return fmt.Errorf("not connected to anvilsrv") } - return writeFile("beads/ctl", []byte(fmt.Sprintf("umount %s", name))) + return writeBeadsFile("ctl", []byte(fmt.Sprintf("umount %s", name))) } diff --git a/cmd/anvilsrv/main.go b/cmd/anvilsrv/main.go index 2c531ad..945ed9c 100644 --- a/cmd/anvilsrv/main.go +++ b/cmd/anvilsrv/main.go @@ -211,17 +211,15 @@ func start(daemonize bool) { } }() - // Start 9P server with empty beads filesystem (mounts created on-demand) - beadsFS := p9.NewBeadsFS(nil, context.Background()) - srv, err := p9.NewServer(mgr, beadsFS) + // Start 9P server (beads served separately by 9beads) + srv, err := p9.NewServer(mgr) if err != nil { logging.Logger().Fatal("failed to start 9P server", zap.Error(err)) } defer srv.Close() - // Wire up event bus to session manager and beads filesystem + // Wire up event bus to session manager mgr.SetEventBus(srv.Events()) - beadsFS.SetEventBus(srv.Events()) // Start maildir writer for message persistence mailDir := filepath.Join(os.Getenv("HOME"), ".local", "share", "anvillm", "mail") diff --git a/internal/p9/beads.go b/internal/p9/beads.go deleted file mode 100644 index 43b0dce..0000000 --- a/internal/p9/beads.go +++ /dev/null @@ -1,1612 +0,0 @@ -package p9 - -import ( - "anvillm/internal/config" - "anvillm/internal/eventbus" - "anvillm/internal/logging" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - bd "github.com/steveyegge/beads" - "github.com/google/uuid" - "go.uber.org/zap" -) - -// ErrAlreadyClaimed is returned when a claim attempt fails because the bead -// is in_progress and its current assignee is alive. -var ErrAlreadyClaimed = errors.New("issue already claimed") - -// Capability level labels — portable model-tier hints carried on beads. -// These are label values of the form "capability:". The Conductor -// maps them to backend-specific model names at session-spawn time. -// -// Convention: use the minimum capable level. When in doubt, prefer lower. -// low — haiku-tier: simple mechanical ops (create bead, update field, -// send message, rename function, edit config) -// standard — sonnet-tier: multi-file edits, moderate reasoning (default) -// high — opus-tier: novel design, ambiguous requirements, long-horizon -// planning, concurrent algorithms, complex state machines -// -// Usage (add to a bead): -// -// echo "label bd-abc capability:low" | 9p write anvillm/beads/ctl -// -// Usage (set at creation time): -// -// echo "new 'title' 'desc' '' capability=low" | 9p write anvillm/beads/ctl -// -// Usage (read via JSON): -// -// 9p read anvillm/beads/bd-abc/json | jq -r .capability_level -// -// Refs: bd-frk.7, bd-frk.14. -const ( - CapabilityLow = "capability:low" - CapabilityStandard = "capability:standard" - CapabilityHigh = "capability:high" - - // capabilityPrefix is the label prefix used to identify capability labels. - capabilityPrefix = "capability:" -) - -// requiresApprovalLabel and requiresReviewLabel are label conventions for -// marking beads that must pass through a human gate before they can be closed. -// Agents should send an APPROVAL_REQUEST (or REVIEW_REQUEST) to the user and -// call `pending-approval ` (or `pending-review `) before completing. -const ( - requiresApprovalLabel = "requires_approval" - requiresReviewLabel = "requires_review" -) - -// scopePrefix is the label prefix for bead scope filtering. -// Scope is the first path component after mount cwd (e.g., "scope:feature-x"). -// Bots filter beads by matching their derived scope from cwd. -const scopePrefix = "scope:" - -// MountedProject represents a project-specific beads instance. -type MountedProject struct { - name string - cwd string - dbPath string - jsonlPath string - store bd.Storage - writeMu sync.Mutex -} - -// BeadsFS handles beads filesystem operations. -// BeadsFS provides 9P filesystem access to beads task tracking. -// It exposes beads operations through virtual files and directories. -type BeadsFS struct { - ctx context.Context - lastQuery *bd.IssueFilter - queryResult []*bd.Issue - mounts map[string]*MountedProject - mountsMu sync.RWMutex - eventbus *eventbus.Bus -} - -// NewBeadsFS creates a beads filesystem handler from an existing store. -func NewBeadsFS(store bd.Storage, ctx context.Context) *BeadsFS { - return &BeadsFS{ - ctx: ctx, - mounts: make(map[string]*MountedProject), - } -} - -// SetEventBus wires an event bus for publishing bead state change events. -func (b *BeadsFS) SetEventBus(eb *eventbus.Bus) { - b.eventbus = eb -} - -// publishBeadReady emits EventBeadReady with the full bead and its comments. -// source is "beads/" so listeners can filter by mount without a round trip. -func (b *BeadsFS) publishBeadReady(store bd.Storage, mountName, beadID string) { - if b.eventbus == nil { - return - } - issue, err := store.GetIssue(b.ctx, beadID) - if err != nil || issue == nil { - b.eventbus.Publish("beads/"+mountName, eventbus.EventBeadReady, map[string]string{"bead_id": beadID}) - return - } - // Fetch labels for scope extraction - if issue.Labels == nil { - labels, err := store.GetLabels(b.ctx, beadID) - if err == nil { - issue.Labels = labels - } - } - comments, _ := store.GetIssueComments(b.ctx, beadID) - type beadReadyPayload struct { - *bd.Issue - Mount string `json:"mount"` - Scope string `json:"scope,omitempty"` - Comments interface{} `json:"comments,omitempty"` - } - payload := beadReadyPayload{ - Issue: issue, - Mount: mountName, - Scope: extractScope(issue.Labels), - Comments: comments, - } - b.eventbus.Publish("beads/"+mountName, eventbus.EventBeadReady, payload) -} - -// Close is a no-op since the store is managed externally. -func (b *BeadsFS) Close() error { - return nil -} - -// ListMounts returns the names of all mounted projects. -func (b *BeadsFS) ListMounts() map[string]struct{} { - b.mountsMu.RLock() - defer b.mountsMu.RUnlock() - result := make(map[string]struct{}, len(b.mounts)) - for name := range b.mounts { - result[name] = struct{}{} - } - return result -} - -// Mount adds a project-specific beads instance. -func (b *BeadsFS) Mount(name, cwd string) error { - b.mountsMu.Lock() - defer b.mountsMu.Unlock() - if _, exists := b.mounts[name]; exists { - return fmt.Errorf("mount %s exists", name) - } - // Check if cwd exists - if _, err := os.Stat(cwd); os.IsNotExist(err) { - return fmt.Errorf("directory does not exist: %s", cwd) - } else if err != nil { - return fmt.Errorf("failed to stat directory: %w", err) - } - // Resolve and validate mount path against project dirs. - // Must be exactly 1 level deep from a project dir. - // If 2 levels deep (immediate child of a valid prjdir), resolve up to the prjdir. - if len(config.ProjectDirs) > 0 { - resolved := false - for _, dir := range config.ProjectDirs { - if !strings.HasPrefix(cwd, dir+"/") { - continue - } - rel := strings.TrimPrefix(cwd, dir+"/") - parts := strings.SplitN(rel, "/", 3) - switch len(parts) { - case 1: - if parts[0] != "" { - resolved = true - } - case 2: - // Immediate child of prjdir — resolve up - cwd = filepath.Join(dir, parts[0]) - resolved = true - } - if resolved { - break - } - } - if !resolved { - return fmt.Errorf("mount path must be at most 1 level below a project dir (%v)", config.ProjectDirs) - } - } - // Reject if cwd is a git worktree (not the main repo) - gitPath := filepath.Join(cwd, ".git") - if info, err := os.Stat(gitPath); err == nil && !info.IsDir() { - return fmt.Errorf("mount path is a git worktree, not a base repo — mount the parent project instead") - } - // Reject if cwd is already mounted - for _, m := range b.mounts { - if m.cwd == cwd { - logging.Logger().Warn("mount path already mounted", zap.String("cwd", cwd), zap.String("existing", m.name)) - return fmt.Errorf("path already mounted as %s", m.name) - } - } - // Warn if new mount overlaps with existing mounts - for _, m := range b.mounts { - if strings.HasPrefix(cwd, m.cwd+"/") { - logging.Logger().Warn("mount is child of existing mount — possible misconfiguration", - zap.String("new", cwd), zap.String("existing", m.cwd)) - } - if strings.HasPrefix(m.cwd, cwd+"/") { - logging.Logger().Warn("mount is parent of existing mount — possible misconfiguration", - zap.String("new", cwd), zap.String("existing", m.cwd)) - } - } - // Use cwd as db path, replacing / with - - cwdHyphenated := strings.ReplaceAll(cwd, "/", "-") - dbPath := filepath.Join(os.Getenv("HOME"), ".beads", cwdHyphenated) - if err := os.MkdirAll(dbPath, 0755); err != nil { - return fmt.Errorf("failed to create db directory: %w", err) - } - jsonlPath := filepath.Join(cwd, ".beads", "issues.jsonl") - store, err := bd.OpenFromConfig(b.ctx, dbPath) - if err != nil { - return err - } - // Import is not available in beads v0.54.0, skip for now - b.mounts[name] = &MountedProject{name: name, cwd: cwd, dbPath: dbPath, jsonlPath: jsonlPath, store: store} - return nil -} - -// Umount removes a project-specific beads instance. -// Accepts either mount name or cwd path. If cwd matches multiple mounts, unmounts first match. -func (b *BeadsFS) Umount(nameOrCwd string) error { - b.mountsMu.Lock() - defer b.mountsMu.Unlock() - - // Try as mount name first - m, ok := b.mounts[nameOrCwd] - mountName := nameOrCwd - if !ok { - // Try as cwd - unmount first match - for name, mount := range b.mounts { - if mount.cwd == nameOrCwd { - m = mount - mountName = name - ok = true - break - } - } - } - - if !ok { - return fmt.Errorf("mount %s not found", nameOrCwd) - } - if err := m.store.Close(); err != nil { - return fmt.Errorf("failed to close store: %w", err) - } - delete(b.mounts, mountName) - return nil -} - -// Sync exports a mounted project's beads to its jsonl file. -func (b *BeadsFS) Sync(name string) error { - b.mountsMu.RLock() - m := b.mounts[name] - b.mountsMu.RUnlock() - if m == nil { - return fmt.Errorf("mount not found") - } - // Export is not available in beads v0.54.0, skip for now - return nil -} - - -// Read handles reads from beads filesystem paths. -// Supports list, ready, pending, stats, blocked, stale, query, config endpoints, -// as well as search, batch, label, and per-bead property access. -func (b *BeadsFS) Read(path string) ([]byte, error) { - // Check for mount paths (format: mountname/endpoint) - parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - if len(parts) >= 2 { - // Check if first part is a mount name - b.mountsMu.RLock() - m := b.mounts[parts[0]] - b.mountsMu.RUnlock() - if m != nil { - endpoint := strings.Join(parts[1:], "/") - if endpoint == "cwd" { - return []byte(m.cwd), nil - } - // Route to mount-specific read - return b.readFromMount(m, endpoint) - } - } - - if len(parts) == 0 { - return nil, fmt.Errorf("invalid path: %s", path) - } - - switch { - case len(parts) == 1 && parts[0] == "mtab": - return b.readMtab() - case len(parts) == 1 && parts[0] == "ready": - // If we have mounts, aggregate ready from all mounts - b.mountsMu.RLock() - hasMounts := len(b.mounts) > 0 - b.mountsMu.RUnlock() - if hasMounts { - return b.readReadyAggregate() - } - return nil, fmt.Errorf("no mounts") - case len(parts) == 1 && parts[0] == "deferred": - b.mountsMu.RLock() - hasMounts := len(b.mounts) > 0 - b.mountsMu.RUnlock() - if hasMounts { - return b.readDeferredAggregate() - } - return nil, fmt.Errorf("no mounts") - default: - return nil, fmt.Errorf("invalid path: %s", path) - } -} - -func (b *BeadsFS) readFromMount(m *MountedProject, endpoint string) ([]byte, error) { - parts := strings.Split(endpoint, "/") - switch { - case len(parts) == 1 && parts[0] == "list": - return b.readListFromStore(m.store, 100) - case len(parts) == 2 && parts[0] == "list": - limit, _ := strconv.Atoi(parts[1]) - if limit == 0 { - limit = 100 - } - return b.readListFromStore(m.store, limit) - case len(parts) == 1 && parts[0] == "ready": - return b.readReadyFromStore(m.store, 100) - case len(parts) == 2 && parts[0] == "ready": - limit, _ := strconv.Atoi(parts[1]) - if limit == 0 { - limit = 100 - } - return b.readReadyFromStore(m.store, limit) - case len(parts) == 1 && parts[0] == "deferred": - return b.readDeferredFromStore(m.store) - case len(parts) == 1 && parts[0] == "blocked": - return b.readBlockedFromStore(m.store) - case len(parts) == 1 && parts[0] == "stale": - return b.readStaleFromStore(m.store) - case len(parts) == 2 && parts[0] == "search": - return b.readSearchFromStore(m.store, parts[1]) - case len(parts) == 2 && parts[0] == "by-ref": - return b.readByExternalRefFromStore(m.store, parts[1]) - case len(parts) == 2 && parts[0] == "batch": - return b.readBatchFromStore(m.store, parts[1]) - case len(parts) == 2 && parts[0] == "label": - return b.readByLabelFromStore(m.store, parts[1]) - case len(parts) == 2 && parts[0] == "children": - return b.readChildrenFromStore(m.store, parts[1]) - case len(parts) == 2: - return b.readBeadPropertyFromStore(m.store, parts[0], parts[1]) - case len(parts) == 3 && parts[2] == "dependencies-meta": - return b.readDependenciesMetaFromStore(m.store, parts[1]) - case len(parts) == 3 && parts[2] == "dependents-meta": - return b.readDependentsMetaFromStore(m.store, parts[1]) - default: - return nil, fmt.Errorf("unsupported mount endpoint: %s", endpoint) - } -} - -// Write handles writes to beads filesystem paths. -// Supports query filter updates and ctl command execution. -func (b *BeadsFS) Write(path string, data []byte, sessionID string) error { - // Check for mount paths (format: mountname/endpoint) - parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - if len(parts) >= 2 { - // Check if first part is a mount name - b.mountsMu.RLock() - m := b.mounts[parts[0]] - b.mountsMu.RUnlock() - if m != nil { - logging.Logger().Info("mount write", zap.String("mount", parts[0]), zap.String("endpoint", strings.Join(parts[1:], "/"))) - endpoint := strings.Join(parts[1:], "/") - return b.writeToMount(m, endpoint, data, sessionID) - } - } - - if len(parts) == 0 { - return fmt.Errorf("invalid path: %s", path) - } - - if len(parts) != 1 || parts[0] != "ctl" { - return fmt.Errorf("write not allowed: %s", path) - } - - // Global ctl commands (mount/umount/sync) - command, args, err := parseCtlCommand(string(data)) - if err != nil { - return err - } - - switch command { - case "mount": - if len(args) < 1 { - return fmt.Errorf("usage: mount [name]") - } - cwd := args[0] - name := uuid.New().String()[:8] - if len(args) >= 2 { - name = args[1] - } - return b.Mount(name, cwd) - case "umount": - if len(args) < 1 { - return fmt.Errorf("usage: umount ") - } - return b.Umount(args[0]) - case "sync": - if len(args) < 1 { - return fmt.Errorf("usage: sync ") - } - return b.Sync(args[0]) - default: - return fmt.Errorf("command %s requires a mounted project", command) - } -} - -func (b *BeadsFS) writeToMount(m *MountedProject, endpoint string, data []byte, sessionID string) error { - m.writeMu.Lock() - defer m.writeMu.Unlock() - parts := strings.Split(endpoint, "/") - if len(parts) == 1 && parts[0] == "ctl" { - logging.Logger().Info("ctl command", zap.String("data", string(data))) - return b.executeCtlOnStore(m.store, m.name, string(data)) - } - return fmt.Errorf("unsupported mount write endpoint: %s", endpoint) -} - - -func (b *BeadsFS) readMtab() ([]byte, error) { - b.mountsMu.RLock() - defer b.mountsMu.RUnlock() - - var lines []string - for name, m := range b.mounts { - lines = append(lines, name+"\t"+m.cwd) - } - return []byte(strings.Join(lines, "\n")), nil -} - -func (b *BeadsFS) readListFromStore(store bd.Storage, limit int) ([]byte, error) { - filter := bd.IssueFilter{ - ExcludeStatus: []bd.Status{bd.StatusClosed}, - Limit: limit, - } - issues, err := store.SearchIssues(b.ctx, "", filter) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - // Sort by ID for hierarchical view (bd-a3f8, bd-a3f8.1, bd-a3f8.1.1, etc.) - sort.Slice(issues, func(i, j int) bool { - return naturalLess(issues[i].ID, issues[j].ID) - }) - - // Enrich with blockers, labels, and capability level. - type BeadWithBlockers struct { - *bd.Issue - Blockers []string `json:"blockers,omitempty"` - CapabilityLevel string `json:"capability_level,omitempty"` - Scope string `json:"scope,omitempty"` - CommentCount int `json:"comment_count,omitempty"` - } - items := make([]BeadWithBlockers, len(issues)) - for i, issue := range issues { - // Fetch labels if not already populated - if issue.Labels == nil { - labels, err := store.GetLabels(b.ctx, issue.ID) - if err == nil { - issue.Labels = labels - } - } - items[i].Issue = issue - blockers, err := b.getBlockersFromStore(store, issue.ID) - if err != nil { - logging.Logger().Warn("failed to get blockers", zap.String("issue", issue.ID), zap.Error(err)) - } else if len(blockers) > 0 { - items[i].Blockers = blockers - } - items[i].CapabilityLevel = extractCapabilityLevel(issue.Labels) - items[i].Scope = extractScope(issue.Labels) - comments, err := store.GetIssueComments(b.ctx, issue.ID) - if err == nil && len(comments) > 0 { - items[i].CommentCount = len(comments) - } - } - - return json.MarshalIndent(items, "", " ") -} - -// naturalLess compares two strings with natural number ordering. -// "jira-100.2" < "jira-100.10" (unlike lexicographic where .10 < .2) -func naturalLess(a, b string) bool { - for { - if a == "" { - return b != "" - } - if b == "" { - return false - } - // Find leading non-digit prefix - ai, bi := 0, 0 - for ai < len(a) && (a[ai] < '0' || a[ai] > '9') { - ai++ - } - for bi < len(b) && (b[bi] < '0' || b[bi] > '9') { - bi++ - } - if a[:ai] != b[:bi] { - return a[:ai] < b[:bi] - } - a, b = a[ai:], b[bi:] - if a == "" || b == "" { - continue - } - // Extract numeric part - ai, bi = 0, 0 - for ai < len(a) && a[ai] >= '0' && a[ai] <= '9' { - ai++ - } - for bi < len(b) && b[bi] >= '0' && b[bi] <= '9' { - bi++ - } - na, _ := strconv.Atoi(a[:ai]) - nb, _ := strconv.Atoi(b[:bi]) - if na != nb { - return na < nb - } - a, b = a[ai:], b[bi:] - } -} - - -func (b *BeadsFS) readReadyFromStore(store bd.Storage, limit int) ([]byte, error) { - filter := bd.WorkFilter{Limit: limit} - issues, err := store.GetReadyWork(b.ctx, filter) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - // Filter out blocked issues - ready means unblocked - ready := []*bd.Issue{} - for _, issue := range issues { - if issue.Labels == nil { - labels, err := store.GetLabels(b.ctx, issue.ID) - if err == nil { - issue.Labels = labels - } - } - blockers, err := b.getBlockersFromStore(store, issue.ID) - if err != nil || len(blockers) == 0 { - ready = append(ready, issue) - } - } - - return json.MarshalIndent(ready, "", " ") -} - -func (b *BeadsFS) readDeferredFromStore(store bd.Storage) ([]byte, error) { - status := bd.StatusDeferred - filter := bd.IssueFilter{Status: &status} - issues, err := store.SearchIssues(b.ctx, "", filter) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - return json.MarshalIndent(issues, "", " ") -} - -func (b *BeadsFS) readDeferredAggregate() ([]byte, error) { - b.mountsMu.RLock() - defer b.mountsMu.RUnlock() - - type TaskWithMount struct { - *bd.Issue - Mount string `json:"mount"` - Cwd string `json:"cwd"` - } - - status := bd.StatusDeferred - filter := bd.IssueFilter{Status: &status} - all := []TaskWithMount{} - for name, m := range b.mounts { - issues, err := m.store.SearchIssues(b.ctx, "", filter) - if err != nil { - continue - } - for _, issue := range issues { - all = append(all, TaskWithMount{Issue: issue, Mount: name, Cwd: m.cwd}) - } - } - return json.MarshalIndent(all, "", " ") -} - -func (b *BeadsFS) readReadyAggregate() ([]byte, error) { - b.mountsMu.RLock() - defer b.mountsMu.RUnlock() - - type TaskWithMount struct { - *bd.Issue - Mount string `json:"mount"` - Cwd string `json:"cwd"` - } - - allReady := []TaskWithMount{} - for name, m := range b.mounts { - filter := bd.WorkFilter{} - issues, err := m.store.GetReadyWork(b.ctx, filter) - if err != nil { - continue - } - // Filter out blocked issues - for _, issue := range issues { - // Fetch labels if not already populated - if issue.Labels == nil { - labels, err := m.store.GetLabels(b.ctx, issue.ID) - if err == nil { - issue.Labels = labels - } - } - blockers, err := b.getBlockersFromStore(m.store, issue.ID) - if err != nil || len(blockers) == 0 { - allReady = append(allReady, TaskWithMount{ - Issue: issue, - Mount: name, - Cwd: m.cwd, - }) - } - } - } - return json.MarshalIndent(allReady, "", " ") -} - - -func (b *BeadsFS) readSearchFromStore(store bd.Storage, query string) ([]byte, error) { - issues, err := store.SearchIssues(b.ctx, query, bd.IssueFilter{}) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - return json.MarshalIndent(issues, "", " ") -} - - -func (b *BeadsFS) readByExternalRefFromStore(store bd.Storage, ref string) ([]byte, error) { - issue, err := store.GetIssueByExternalRef(b.ctx, ref) - if err != nil { - return nil, err - } - return json.MarshalIndent(issue, "", " ") -} - - - - -func (b *BeadsFS) readBatchFromStore(store bd.Storage, ids string) ([]byte, error) { - idList := strings.Split(ids, ",") - issues, err := store.GetIssuesByIDs(b.ctx, idList) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - return json.MarshalIndent(issues, "", " ") -} - - -func (b *BeadsFS) readByLabelFromStore(store bd.Storage, label string) ([]byte, error) { - issues, err := store.GetIssuesByLabel(b.ctx, label) - if err != nil { - return nil, err - } - if issues == nil { - issues = []*bd.Issue{} - } - return json.MarshalIndent(issues, "", " ") -} - - -func (b *BeadsFS) readBlockedFromStore(store bd.Storage) ([]byte, error) { - filter := bd.WorkFilter{} - blocked, err := store.GetBlockedIssues(b.ctx, filter) - if err != nil { - return nil, err - } - if blocked == nil { - blocked = []*bd.BlockedIssue{} - } - return json.MarshalIndent(blocked, "", " ") -} - -func (b *BeadsFS) readDependenciesMetaFromStore(store bd.Storage, beadID string) ([]byte, error) { - deps, err := store.GetDependenciesWithMetadata(b.ctx, beadID) - if err != nil { - return nil, err - } - return json.MarshalIndent(deps, "", " ") -} - - -func (b *BeadsFS) readDependentsMetaFromStore(store bd.Storage, beadID string) ([]byte, error) { - deps, err := store.GetDependentsWithMetadata(b.ctx, beadID) - if err != nil { - return nil, err - } - return json.MarshalIndent(deps, "", " ") -} - - -func (b *BeadsFS) readChildrenFromStore(store bd.Storage, parentID string) ([]byte, error) { - filter := bd.IssueFilter{ParentID: &parentID} - children, err := store.SearchIssues(b.ctx, "", filter) - if err != nil { - return nil, err - } - if children == nil { - children = []*bd.Issue{} - } - return json.MarshalIndent(children, "", " ") -} - - -func (b *BeadsFS) readStaleFromStore(store bd.Storage) ([]byte, error) { - // Get all non-closed issues - filter := bd.IssueFilter{} - issues, err := store.SearchIssues(b.ctx, "", filter) - if err != nil { - return nil, err - } - - // Filter to stale issues (not updated in 30+ days, status open or in_progress) - cutoff := time.Now().AddDate(0, 0, -30) - stale := []*bd.Issue{} - for _, issue := range issues { - if issue.Status != bd.StatusClosed && issue.UpdatedAt.Before(cutoff) { - stale = append(stale, issue) - } - } - - return json.MarshalIndent(stale, "", " ") -} - - -func (b *BeadsFS) readQuery() ([]byte, error) { - if b.queryResult == nil { - return json.MarshalIndent([]*bd.Issue{}, "", " ") - } - return json.MarshalIndent(b.queryResult, "", " ") -} - - - -func (b *BeadsFS) readBeadPropertyFromStore(store bd.Storage, beadID, property string) ([]byte, error) { - issue, err := store.GetIssue(b.ctx, beadID) - if err != nil { - return nil, err - } - if issue == nil { - return nil, fmt.Errorf("bead not found: %s", beadID) - } - - switch property { - case "status": - return []byte(string(issue.Status)), nil - case "title": - return []byte(issue.Title), nil - case "description": - return []byte(issue.Description), nil - case "assignee": - return []byte(issue.Assignee), nil - case "json": - type BeadWithBlockers struct { - *bd.Issue - Blockers []string `json:"blockers,omitempty"` - CapabilityLevel string `json:"capability_level,omitempty"` - Scope string `json:"scope,omitempty"` - CommentCount int `json:"comment_count,omitempty"` - } - result := BeadWithBlockers{Issue: issue} - if blockers, err := b.getBlockersFromStore(store, beadID); err == nil { - result.Blockers = blockers - } - result.CapabilityLevel = extractCapabilityLevel(issue.Labels) - result.Scope = extractScope(issue.Labels) - comments, err := store.GetIssueComments(b.ctx, beadID) - if err == nil && len(comments) > 0 { - result.CommentCount = len(comments) - } - return json.MarshalIndent(result, "", " ") - case "comments": - comments, err := store.GetIssueComments(b.ctx, beadID) - if err != nil { - return nil, err - } - return json.MarshalIndent(comments, "", " ") - case "labels": - labels, err := store.GetLabels(b.ctx, beadID) - if err != nil { - return nil, err - } - return json.MarshalIndent(labels, "", " ") - case "dependents": - dependents, err := store.GetDependents(b.ctx, beadID) - if err != nil { - return nil, err - } - return json.MarshalIndent(dependents, "", " ") - case "tree": - tree, err := store.GetDependencyTree(b.ctx, beadID, 10, false, false) - if err != nil { - return nil, err - } - return json.MarshalIndent(tree, "", " ") - case "events": - events, err := store.GetEvents(b.ctx, beadID, 100) - if err != nil { - return nil, err - } - return json.MarshalIndent(events, "", " ") - default: - return nil, fmt.Errorf("unknown property: %s", property) - } -} - - -func (b *BeadsFS) getBlockersFromStore(store bd.Storage, id string) ([]string, error) { - deps, err := store.GetDependenciesWithMetadata(b.ctx, id) - if err != nil { - return nil, err - } - var blockers []string - for _, dep := range deps { - if dep.DependencyType == bd.DepBlocks && dep.Status != bd.StatusClosed { - blockers = append(blockers, dep.ID) - } - } - return blockers, nil -} - - -func (b *BeadsFS) executeCtlOnStore(store bd.Storage, mountName, cmd string) error { - command, args, err := parseCtlCommand(cmd) - if err != nil { - return err - } - - actor := "user" - - // Handle batch-create specially — needs raw JSON - if command == "batch-create" { - jsonStr := strings.TrimSpace(strings.TrimPrefix(cmd, "batch-create")) - if jsonStr == "" { - return fmt.Errorf("usage: batch-create ") - } - type issueWithScope struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - Scope string `json:"scope,omitempty"` - } - var items []issueWithScope - if err := json.Unmarshal([]byte(jsonStr), &items); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - for _, item := range items { - issue := &bd.Issue{ - Title: item.Title, - Description: item.Description, - Status: bd.StatusDeferred, - IssueType: bd.TypeTask, - Priority: 2, - } - if err := store.CreateIssue(b.ctx, issue, actor); err != nil { - return err - } - if item.Scope != "" { - _ = store.AddLabel(b.ctx, issue.ID, scopePrefix+item.Scope, actor) - } - } - return nil - } - - switch command { - case "init": - prefix := "bd" - if len(args) > 0 { - prefix = args[0] - } - return store.SetConfig(b.ctx, "issue_prefix", prefix) - - case "new", "create": - if len(args) < 1 { - return fmt.Errorf("usage: new 'title' ['description'] [parent-id] [--no-lint] [capability=low|standard|high] [scope=] [blockers=id1,id2,...]") - } - - // Strip flags and options from args before positional parsing. - noLint := false - capLevel := "" - scopeVal := "" - var blockerIDs []string - filtered := args[:0] - for _, a := range args { - if a == "--no-lint" { - noLint = true - } else if level, ok := strings.CutPrefix(a, "capability="); ok { - capLevel = level - } else if s, ok := strings.CutPrefix(a, "scope="); ok { - scopeVal = s - } else if ids, ok := strings.CutPrefix(a, "blockers="); ok { - for _, id := range strings.Split(ids, ",") { - if id = strings.TrimSpace(id); id != "" { - blockerIDs = append(blockerIDs, id) - } - } - } else { - filtered = append(filtered, a) - } - } - args = filtered - - title := args[0] - description := "" - parentID := "" - if len(args) > 1 { - description = args[1] - } - if len(args) > 2 { - parentID = args[2] - } - - // Emit lint warnings unless --no-lint or issue_type=idea. - isIdea := strings.HasPrefix(strings.ToUpper(title), "IDEA:") - if !noLint && !isIdea && description != "" { - for _, w := range lintDescription(description) { - logging.Logger().Warn("lint warning", zap.String("title", title), zap.String("warning", w)) - } - } - - // Build initial labels - var labels []string - if capLevel != "" { - labels = append(labels, capabilityPrefix+capLevel) - } - if scopeVal != "" { - labels = append(labels, scopePrefix+scopeVal) - } - - var newID string - if parentID != "" { - childID, err := b.createSubtaskOnStore(store, parentID, title, description, actor) - if err != nil { - return err - } - newID = childID - for _, lbl := range labels { - _ = store.AddLabel(b.ctx, childID, lbl, actor) - } - } else { - issue := &bd.Issue{ - Title: title, - Description: description, - Status: bd.StatusDeferred, - IssueType: bd.TypeTask, - Priority: 2, - } - if err := store.CreateIssue(b.ctx, issue, actor); err != nil { - return err - } - newID = issue.ID - for _, lbl := range labels { - _ = store.AddLabel(b.ctx, newID, lbl, actor) - } - } - - // Add blockers - for _, blockerID := range blockerIDs { - dep := &bd.Dependency{ - IssueID: newID, - DependsOnID: blockerID, - Type: bd.DepBlocks, - } - if err := store.AddDependency(b.ctx, dep, actor); err != nil { - logging.Logger().Warn("failed to add blocker", zap.String("bead", newID), zap.String("blocker", blockerID), zap.Error(err)) - } - } - return nil - - case "claim": - if len(args) < 1 { - return fmt.Errorf("usage: claim [assignee]") - } - assignee := "user" - if len(args) > 1 { - assignee = args[1] - } - beadID := args[0] - if err := store.RunInTransaction(b.ctx, func(tx bd.Transaction) error { - issue, err := tx.GetIssue(b.ctx, beadID) - if err != nil { - return err - } - if issue == nil { - return fmt.Errorf("bead not found: %s", beadID) - } - if issue.Status != bd.StatusOpen { - return fmt.Errorf("%w: %s (status: %s)", ErrAlreadyClaimed, beadID, issue.Status) - } - return tx.UpdateIssue(b.ctx, beadID, map[string]any{ - "assignee": assignee, - "status": bd.StatusInProgress, - }, actor) - }); err != nil { - return err - } - if b.eventbus != nil { - b.eventbus.Publish("beads/"+mountName, eventbus.EventBeadClaimed, map[string]string{ - "bead_id": beadID, - "assignee": assignee, - "mount": mountName, - }) - } - return nil - - case "complete", "close": - if len(args) < 1 { - return fmt.Errorf("usage: complete ") - } - if err := store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor); err != nil { - return err - } - return store.CloseIssue(b.ctx, args[0], "completed", actor, "") - - case "fail": - if len(args) < 2 { - return fmt.Errorf("usage: fail 'reason'") - } - if err := store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor); err != nil { - return err - } - return store.CloseIssue(b.ctx, args[0], args[1], actor, "") - - case "dep", "add-dep": - if len(args) < 2 { - return fmt.Errorf("usage: dep ") - } - dep := &bd.Dependency{ - IssueID: args[0], - DependsOnID: args[1], - Type: bd.DepBlocks, - } - return store.AddDependency(b.ctx, dep, actor) - - case "undep", "rm-dep": - if len(args) < 2 { - return fmt.Errorf("usage: undep ") - } - return store.RemoveDependency(b.ctx, args[0], args[1], actor) - - case "update": - // Update a single bead field directly. - // Fields managed by atomic operations are rejected here: - // status, assignee, closed_at, close_reason, defer_until - // Use: open, defer, reopen, claim, unclaim, complete, fail, - // pending-approval, pending-review, resume instead. - if len(args) < 3 { - return fmt.Errorf("usage: update 'value'") - } - atomicFields := map[string]string{ - "status": "use open/defer/reopen/claim/unclaim/complete/fail/pending-approval/pending-review/resume", - "assignee": "use claim/unclaim/resume/pending-approval/pending-review", - "closed_at": "use complete/fail/reopen", - "close_reason": "use complete/fail", - "defer_until": "use defer", - } - if hint, reserved := atomicFields[args[1]]; reserved { - return fmt.Errorf("field %q is managed by atomic operations: %s", args[1], hint) - } - return store.UpdateIssue(b.ctx, args[0], map[string]any{args[1]: args[2]}, actor) - - case "delete", "rm": - if len(args) < 1 { - return fmt.Errorf("usage: delete ") - } - return store.DeleteIssue(b.ctx, args[0]) - - case "comment": - if len(args) < 2 { - return fmt.Errorf("usage: comment 'text'") - } - _, err := store.AddIssueComment(b.ctx, args[0], actor, args[1]) - return err - - case "label": - if len(args) < 2 { - return fmt.Errorf("usage: label 'label'") - } - return store.AddLabel(b.ctx, args[0], args[1], actor) - - case "unlabel": - if len(args) < 2 { - return fmt.Errorf("usage: unlabel 'label'") - } - return store.RemoveLabel(b.ctx, args[0], args[1], actor) - - case "set-capability": - // Convenience command that replaces any existing capability label with - // the given level (low, standard, or high). - // Usage: set-capability low|standard|high - if len(args) < 2 { - return fmt.Errorf("usage: set-capability low|standard|high") - } - beadID := args[0] - level := args[1] - if level != "low" && level != "standard" && level != "high" { - return fmt.Errorf("invalid capability level %q: must be low, standard, or high", level) - } - // Remove any existing capability labels first. - existing, err := store.GetLabels(b.ctx, beadID) - if err != nil { - return err - } - for _, lbl := range existing { - if level, ok := strings.CutPrefix(lbl, capabilityPrefix); ok && level != "" { - _ = store.RemoveLabel(b.ctx, beadID, lbl, actor) - } - } - return store.AddLabel(b.ctx, beadID, capabilityPrefix+level, actor) - - case "unclaim": - // Atomically clear assignee and reset status to open. - // Usage: unclaim - if len(args) < 1 { - return fmt.Errorf("usage: unclaim ") - } - updates := map[string]any{ - "assignee": "", - "status": bd.StatusOpen, - } - if err := store.UpdateIssue(b.ctx, args[0], updates, actor); err != nil { - return err - } - b.publishBeadReady(store, mountName, args[0]) - return nil - - case "open": - // Promote a deferred bead to open (ready for scheduling). - // Usage: open - if len(args) < 1 { - return fmt.Errorf("usage: open ") - } - if err := store.UpdateIssue(b.ctx, args[0], map[string]any{"status": bd.StatusOpen}, actor); err != nil { - return err - } - b.publishBeadReady(store, mountName, args[0]) - return nil - - case "defer": - // Defer a bead, optionally until a specific time (RFC3339). - // Usage: defer [until ] - if len(args) < 1 { - return fmt.Errorf("usage: defer [until ]") - } - updates := map[string]any{"status": bd.StatusDeferred} - if len(args) >= 3 && args[1] == "until" { - t, err := time.Parse(time.RFC3339, args[2]) - if err != nil { - return fmt.Errorf("invalid time %q: expected RFC3339 format", args[2]) - } - updates["defer_until"] = t - } - return store.UpdateIssue(b.ctx, args[0], updates, actor) - - case "reopen": - // Reopen a closed bead (reset to open, clear closed_at). - // Usage: reopen - if len(args) < 1 { - return fmt.Errorf("usage: reopen ") - } - updates := map[string]any{ - "status": bd.StatusOpen, - "closed_at": (*time.Time)(nil), - } - if err := store.UpdateIssue(b.ctx, args[0], updates, actor); err != nil { - return err - } - b.publishBeadReady(store, mountName, args[0]) - return nil - - case "relate": - // Add a bidirectional relates-to dependency between two beads. - // Usage: relate - if len(args) < 2 { - return fmt.Errorf("usage: relate ") - } - dep := &bd.Dependency{ - IssueID: args[0], - DependsOnID: args[1], - Type: bd.DepRelated, - } - return store.AddDependency(b.ctx, dep, actor) - - case "unrelate": - // Remove a relates-to dependency between two beads. - // Usage: unrelate - if len(args) < 2 { - return fmt.Errorf("usage: unrelate ") - } - return store.RemoveDependency(b.ctx, args[0], args[1], actor) - - case "config": - // Set a beads configuration key. - // Usage: config - if len(args) < 2 { - return fmt.Errorf("usage: config ") - } - return store.SetConfig(b.ctx, args[0], args[1]) - - default: - return fmt.Errorf("unknown command: %s (supported: init, new, update, delete, claim, unclaim, open, defer, reopen, complete, fail, dep, undep, relate, unrelate, pending-approval, pending-review, resume, label, unlabel, set-capability, config)", command) - } -} - -// lintDescription checks a bead description for quality signals and returns -// a list of warning messages. An empty slice means the description passed. -// -// Rules enforced: -// - File path present (.go/.py/etc or /internal/ etc.) -// - Function name or precise location (foo(), func keyword, Acme address, L123) -// - Minimum length (80 chars) -// - Acceptance criterion keyword (should/must/returns/etc.) -// - Acme address format (file:N,N not file:N-N) -// - Imperative verb start (Fix/Add/Update/... not "Need to"/"Should") -// - No vague language (somehow/maybe/etc.) -// - "How" signal (following/same as/pattern from/...) -// - No first-person voice (I need/we want/...) -// - No forbidden vague phrases (fix this/update this/...) -// - Inline code (backtick identifier required when file path present) -// - Cross-reference on long descriptions (bd-XXX or URL for >150 chars) -func lintDescription(description string) []string { - var warnings []string - lower := strings.ToLower(description) - - // --- Rule 1: File path signal --- - hasFilePath := strings.Contains(description, ".go") || - strings.Contains(description, ".py") || - strings.Contains(description, ".ts") || - strings.Contains(description, ".js") || - strings.Contains(description, ".rs") || - strings.Contains(description, "/cmd/") || - strings.Contains(description, "/internal/") || - strings.Contains(description, "/src/") || - strings.Contains(description, "/pkg/") - if !hasFilePath { - warnings = append(warnings, "missing file path (add .go/.py/etc or /cmd//internal/ to help bots locate the code)") - } - - // --- Rule 2: Function name or precise location --- - // Matches: func calls (foo()), "func " keyword, Acme addresses (file:NNN or file:/re/), - // explicit "line " text, or L123 / :123 style refs. - hasFuncOrLine := strings.Contains(description, "()") || - strings.Contains(description, "func ") || - strings.Contains(lower, "line ") || - strings.Contains(lower, ":line") || - containsLineRef(description) || - containsAcmeRegexAddr(description) - if !hasFuncOrLine { - warnings = append(warnings, "missing function name or location (add func name, Acme address file.go:123, or L123)") - } - - // --- Rule 3: Minimum length --- - if len(description) < 80 { - warnings = append(warnings, "description too short (aim for 80+ chars with What/Where/How/Accept)") - } - - // --- Rule 4: Acceptance criterion keyword --- - acceptKeywords := []string{"should", "returns", "displays", "must", "assert", "verify", "accept", "expect"} - hasAccept := false - for _, kw := range acceptKeywords { - if strings.Contains(lower, kw) { - hasAccept = true - break - } - } - if !hasAccept { - warnings = append(warnings, "missing acceptance criterion (add: should/returns/must/accept)") - } - - // --- Rule 5: Acme address format --- - // file:NNN-NNN uses a hyphen range which is invalid in Acme/sam; use comma: file:NNN,NNN. - if containsHyphenRange(description) { - warnings = append(warnings, "invalid Acme address: use comma range file.go:123,125 not file.go:123-125") - } - - // --- Rule 6: Imperative verb start --- - // Warn if description begins with a known non-imperative pattern. - nonImperativeStarters := []string{ - "need to ", "needs to ", "should ", "we need", "we want", "we should", - "the ", "this ", "looking at", "looking into", - } - firstWordLower := strings.ToLower(strings.TrimSpace(description)) - for _, starter := range nonImperativeStarters { - if strings.HasPrefix(firstWordLower, starter) { - warnings = append(warnings, "start with imperative verb (Fix/Add/Update/Refactor) not '"+starter+"...'") - break - } - } - - // --- Rule 7: No vague language --- - vaguePhrases := []string{ - "somehow", " maybe ", "probably ", "try to ", " a bit ", " etc.", " etc,", - "and so on", " stuff", "some kind of", "whatever", "sort of ", "kind of ", - } - for _, phrase := range vaguePhrases { - if strings.Contains(lower, phrase) { - warnings = append(warnings, "vague language '"+strings.TrimSpace(phrase)+"': replace with specific behavior") - break - } - } - - // --- Rule 8: "How" signal (existing pattern reference) --- - howSignals := []string{ - "following ", "pattern from", "same as ", "similar to ", "like in ", - "mirrors ", "as in ", "modeled on", "following the ", "see ", "cf.", - } - hasHow := false - for _, sig := range howSignals { - if strings.Contains(lower, sig) { - hasHow = true - break - } - } - if !hasHow { - warnings = append(warnings, "missing 'how' signal (add: 'following pattern in X' or 'same as Y')") - } - - // --- Rule 9: No first-person voice --- - firstPersonPhrases := []string{ - "i need", "i want", "i think", "i'll ", "i will ", "i should", - "we need", "we want", "we should", "we'll ", "we will ", - } - for _, fp := range firstPersonPhrases { - if strings.Contains(lower, fp) { - warnings = append(warnings, "avoid first-person ('"+strings.TrimSpace(fp)+"'): use imperative voice") - break - } - } - - // --- Rule 10: Forbidden vague phrases --- - forbiddenPhrases := []string{ - "fix this", "fix it", "update this", "make it work", "clean this up", - "refactor this", "look at this", "deal with this", "handle this", - } - for _, fp := range forbiddenPhrases { - if strings.Contains(lower, fp) { - warnings = append(warnings, "forbidden vague phrase '"+fp+"': specify What/Where/How/Accept") - break - } - } - - // --- Rule 11: Inline code (backtick identifier) --- - // When a file path is present, at least one backtick-enclosed identifier is expected. - if hasFilePath && !strings.Contains(description, "`") { - warnings = append(warnings, "no inline code found: wrap identifiers in backticks (`funcName()`, `--flag`)") - } - - // --- Rule 12: Cross-reference on long descriptions --- - // Descriptions over 150 chars should reference a related bead or URL. - if len(description) > 150 { - hasBdRef := containsBdRef(description) - hasURL := strings.Contains(lower, "http://") || strings.Contains(lower, "https://") - if !hasBdRef && !hasURL { - warnings = append(warnings, "long description missing cross-reference (add bd-XXX or URL in Refs)") - } - } - - return warnings -} - -// containsLineRef returns true if s contains a line number reference -// in the form L (e.g. L385) or : (e.g. :385). -// Also recognises Acme character-position addresses: #. -func containsLineRef(s string) bool { - for i := 0; i < len(s)-1; i++ { - c := s[i] - next := s[i+1] - if (c == 'L' || c == ':' || c == '#') && next >= '0' && next <= '9' { - return true - } - } - return false -} - -// containsAcmeRegexAddr returns true if s contains an Acme regex-style address -// of the form /word/ where word is at least 4 characters long -// (e.g. /lintDescription/ or /funcName/). Short slash-delimited tokens -// such as path components (/p9/, /cmd/, /src/) are intentionally excluded. -func containsAcmeRegexAddr(s string) bool { - const minPatternLen = 4 - for i := 0; i < len(s)-2; i++ { - if s[i] != '/' { - continue - } - // Scan for closing slash; stop at whitespace or newline. - j := i + 1 - for j < len(s) && s[j] != '/' && s[j] != ' ' && s[j] != '\n' { - j++ - } - if j < len(s) && s[j] == '/' && (j-i-1) >= minPatternLen { - return true - } - } - return false -} - -// containsHyphenRange returns true if s contains an Acme-invalid hyphen range -// of the form file.ext:NNN-NNN (e.g. beads.go:123-125). -// The correct Acme syntax uses a comma: beads.go:123,125. -func containsHyphenRange(s string) bool { - // Look for patterns like ":NNN-NNN" (colon, digits, hyphen, digits). - for i := 0; i < len(s); i++ { - if s[i] != ':' { - continue - } - i++ - // Consume leading digits. - if i >= len(s) || s[i] < '0' || s[i] > '9' { - continue - } - for i < len(s) && s[i] >= '0' && s[i] <= '9' { - i++ - } - // Expect hyphen followed by digits. - if i < len(s)-1 && s[i] == '-' && s[i+1] >= '0' && s[i+1] <= '9' { - return true - } - } - return false -} - -// containsBdRef returns true if s contains a bead cross-reference of the -// form "bd-" followed by one or more lowercase alphanumeric characters. -func containsBdRef(s string) bool { - lower := strings.ToLower(s) - idx := strings.Index(lower, "bd-") - for idx != -1 { - if idx+3 < len(lower) && isAlphanumeric(lower[idx+3]) { - return true - } - next := strings.Index(lower[idx+1:], "bd-") - if next == -1 { - break - } - idx = idx + 1 + next - } - return false -} - -// isAlphanumeric returns true if b is a lowercase letter or digit. -func isAlphanumeric(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') -} - - -func (b *BeadsFS) createSubtaskOnStore(store bd.Storage, parentID, title, description, actor string) (string, error) { - // Verify parent exists - parent, err := store.GetIssue(b.ctx, parentID) - if err != nil { - return "", fmt.Errorf("failed to get parent: %w", err) - } - if parent == nil { - return "", fmt.Errorf("parent %s not found", parentID) - } - - // Find next child number by scanning existing IDs with parent filter - filter := bd.IssueFilter{ParentID: &parentID} - children, err := store.SearchIssues(b.ctx, "", filter) - if err != nil { - return "", fmt.Errorf("failed to search children: %w", err) - } - - nextChild := 1 - prefix := parentID + "." - for _, issue := range children { - if suffix, ok := strings.CutPrefix(issue.ID, prefix); ok { - if !strings.Contains(suffix, ".") { - if n, err := strconv.Atoi(suffix); err == nil && n >= nextChild { - nextChild = n + 1 - } - } - } - } - - childID := fmt.Sprintf("%s.%d", parentID, nextChild) - - issue := &bd.Issue{ - ID: childID, - Title: title, - Description: description, - Status: bd.StatusDeferred, - IssueType: bd.TypeTask, - Priority: 2, - } - - if err := store.CreateIssue(b.ctx, issue, actor); err != nil { - return "", err - } - return childID, nil -} - -// extractCapabilityLevel scans a label slice and returns the level portion -// (low, standard, or high) of the first "capability:" label found. -// Returns "" if no capability label is present or level is invalid. -func extractCapabilityLevel(labels []string) string { - for _, lbl := range labels { - if level, ok := strings.CutPrefix(lbl, capabilityPrefix); ok && level != "" { - if level == "low" || level == "standard" || level == "high" { - return level - } - } - } - return "" -} - -// extractScope scans a label slice and returns the scope value from -// the first "scope:" label found. Returns "" if none. -func extractScope(labels []string) string { - for _, lbl := range labels { - if scope, ok := strings.CutPrefix(lbl, scopePrefix); ok { - return scope - } - } - return "" -} - -func parseCtlCommand(cmd string) (string, []string, error) { - parts := strings.Fields(cmd) - if len(parts) == 0 { - return "", nil, fmt.Errorf("empty command") - } - - command := parts[0] - args := parseQuotedArgs(strings.TrimPrefix(cmd, command)) - return command, args, nil -} - -func parseQuotedArgs(s string) []string { - var args []string - var current strings.Builder - var quoteChar rune - var wasQuoted bool - - s = strings.TrimSpace(s) - for _, c := range s { - if quoteChar != 0 { - // Inside quotes - if c == quoteChar { - quoteChar = 0 // End quote - wasQuoted = true - } else { - current.WriteRune(c) - } - } else { - // Outside quotes - switch c { - case '\'', '"': - quoteChar = c // Start quote - case ' ', '\t': - if current.Len() > 0 || wasQuoted { - args = append(args, current.String()) - current.Reset() - wasQuoted = false - } - default: - current.WriteRune(c) - } - } - } - - if current.Len() > 0 || wasQuoted { - args = append(args, current.String()) - } - - return args -} diff --git a/internal/p9/beads_lint_test.go b/internal/p9/beads_lint_test.go deleted file mode 100644 index ac536c5..0000000 --- a/internal/p9/beads_lint_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package p9 - -import ( - "strings" - "testing" -) - -// goodDesc is a well-formed description that should pass all lint rules. -// It contains: file path, backtick identifier, Acme address, acceptance -// criterion, how signal, imperative start, and a cross-reference. -const goodDesc = "Add `lintDescription()` check in `internal/p9/beads.go:615,663` following the pattern of existing checks; the function must return a non-empty warnings slice when the description violates any rule. See bd-9y4 for full rule list." - -func warnCount(desc, substr string) bool { - for _, w := range lintDescription(desc) { - if strings.Contains(w, substr) { - return true - } - } - return false -} - -// TestGoodDescPassesAll verifies that a high-quality description triggers no warnings. -func TestGoodDescPassesAll(t *testing.T) { - warnings := lintDescription(goodDesc) - if len(warnings) != 0 { - t.Errorf("expected no warnings for good description, got: %v", warnings) - } -} - -// --- Rule 1: File path --- - -func TestMissingFilePath(t *testing.T) { - desc := "Add a helper function following the existing pattern; it must return an error when the input is empty." - if !warnCount(desc, "missing file path") { - t.Error("expected 'missing file path' warning") - } -} - -func TestFilePathPresent(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following the existing pattern; it must return an error when input is empty. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "missing file path") { - t.Errorf("unexpected 'missing file path' warning for desc with .go extension") - } - } -} - -// --- Rule 2: Function name or location --- - -func TestMissingFuncOrLine(t *testing.T) { - desc := "Update internal/p9/beads.go following the existing pattern; the file must compile without errors after the change. See bd-abc for context." - if !warnCount(desc, "missing function name") { - t.Error("expected 'missing function name or location' warning") - } -} - -func TestFuncNamePresent(t *testing.T) { - desc := "Fix `lintDescription()` in `internal/p9/beads.go` following the existing pattern; it must return warnings for vague descriptions. See bd-9y4." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "missing function name") { - t.Errorf("unexpected location warning when func name present") - } - } -} - -func TestAcmeLineAddrRecognised(t *testing.T) { - desc := "Fix `foo()` in `cmd/server.go:385` following the existing error-handling pattern; it must return a wrapped error. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "missing function name") { - t.Errorf("unexpected location warning when Acme line address present") - } - } -} - -func TestAcmeRegexAddrRecognised(t *testing.T) { - desc := "Update `internal/p9/beads.go:/lintDescription/` following the existing pattern; it must emit a warning for vague language. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "missing function name") { - t.Errorf("unexpected location warning when Acme /regex/ address present") - } - } -} - -// --- Rule 3: Minimum length --- - -func TestTooShort(t *testing.T) { - if !warnCount("Fix bug.", "too short") { - t.Error("expected 'too short' warning for very short description") - } -} - -// --- Rule 4: Acceptance criterion --- - -func TestMissingAcceptance(t *testing.T) { - desc := "Add `helper()` to `internal/util.go:42` following the existing pattern; the function handles empty input gracefully with a cross-reference to bd-abc." - if !warnCount(desc, "acceptance criterion") { - t.Error("expected 'acceptance criterion' warning") - } -} - -func TestAcceptancePresent(t *testing.T) { - desc := "Add `helper()` to `internal/util.go:42` following the existing pattern; it must return an error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "acceptance criterion") { - t.Errorf("unexpected acceptance warning when 'must' keyword present") - } - } -} - -// --- Rule 5: Acme address format --- - -func TestHyphenRangeWarning(t *testing.T) { - desc := "Fix `lintDescription()` in `internal/p9/beads.go:611-663` following the existing pattern; it must return warnings correctly. See bd-abc for context." - if !warnCount(desc, "invalid Acme address") { - t.Error("expected 'invalid Acme address' warning for hyphen range") - } -} - -func TestCommaRangeNoWarning(t *testing.T) { - desc := "Fix `lintDescription()` in `internal/p9/beads.go:611,663` following the existing pattern; it must return warnings correctly. See bd-abc for context." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "invalid Acme address") { - t.Errorf("unexpected Acme address warning for correct comma range") - } - } -} - -// --- Rule 6: Imperative verb start --- - -func TestNonImperativeStart(t *testing.T) { - desc := "Need to add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - if !warnCount(desc, "imperative verb") { - t.Error("expected imperative verb warning for 'Need to' start") - } -} - -func TestImperativeStart(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "imperative verb") { - t.Errorf("unexpected imperative verb warning for description starting with 'Add'") - } - } -} - -// --- Rule 7: Vague language --- - -func TestVagueLanguage(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must somehow return an error etc. See bd-abc." - if !warnCount(desc, "vague language") { - t.Error("expected vague language warning for 'somehow'") - } -} - -func TestNoVagueLanguage(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must return a wrapped error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "vague language") { - t.Errorf("unexpected vague language warning for precise description") - } - } -} - -// --- Rule 8: "How" signal --- - -func TestMissingHowSignal(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42`; it must return an error on empty input. Cross-reference: bd-abc." - if !warnCount(desc, "how' signal") { - t.Error("expected 'how' signal warning") - } -} - -func TestHowSignalPresent(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following the existing error-handling pattern; it must return an error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "how' signal") { - t.Errorf("unexpected 'how' warning when 'following' keyword present") - } - } -} - -// --- Rule 9: No first-person --- - -func TestFirstPersonWarning(t *testing.T) { - desc := "I need to add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - if !warnCount(desc, "first-person") { - t.Error("expected first-person warning for 'I need'") - } -} - -func TestNoFirstPerson(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "first-person") { - t.Errorf("unexpected first-person warning for imperative description") - } - } -} - -// --- Rule 10: Forbidden vague phrases --- - -func TestForbiddenPhrase(t *testing.T) { - desc := "Fix this in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - if !warnCount(desc, "forbidden vague phrase") { - t.Error("expected forbidden phrase warning for 'fix this'") - } -} - -func TestNoForbiddenPhrase(t *testing.T) { - desc := "Fix `lintDescription()` in `internal/p9/beads.go:615` following the existing pattern; it must return warnings for vague descriptions. See bd-9y4." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "forbidden vague phrase") { - t.Errorf("unexpected forbidden phrase warning for well-formed description") - } - } -} - -// --- Rule 11: Inline code (backticks) --- - -func TestMissingBacktick(t *testing.T) { - desc := "Add helper() in internal/util.go:42 following existing pattern; it must return error on empty input. See bd-abc." - if !warnCount(desc, "inline code") { - t.Error("expected inline code warning when file path present but no backticks") - } -} - -func TestBacktickPresent(t *testing.T) { - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input. See bd-abc." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "inline code") { - t.Errorf("unexpected inline code warning when backtick identifier present") - } - } -} - -// --- Rule 12: Cross-reference on long descriptions --- - -func TestLongDescMissingCrossRef(t *testing.T) { - // >150 chars, has file path and backtick, but no bd-XXX or URL - desc := "Add `lintDescription()` check in `internal/p9/beads.go:615` following the pattern of existing checks; the function must return a non-empty warnings slice when the description violates any rule and emit all applicable warnings." - if !warnCount(desc, "cross-reference") { - t.Error("expected cross-reference warning for long description without bd- ref") - } -} - -func TestLongDescWithCrossRef(t *testing.T) { - desc := "Add `lintDescription()` check in `internal/p9/beads.go:615` following the pattern of existing checks; the function must return a non-empty warnings slice when the description violates any rule and emit all applicable warnings. See bd-9y4." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "cross-reference") { - t.Errorf("unexpected cross-reference warning when bd- ref present") - } - } -} - -func TestShortDescNoCrossRefWarning(t *testing.T) { - // <=150 chars should not trigger the cross-reference rule - desc := "Add `helper()` in `internal/util.go:42` following existing pattern; it must return error on empty input." - for _, w := range lintDescription(desc) { - if strings.Contains(w, "cross-reference") { - t.Errorf("unexpected cross-reference warning for short description") - } - } -} - -// --- Helper function unit tests --- - -func TestContainsLineRef(t *testing.T) { - cases := []struct { - s string - want bool - }{ - {"beads.go:385", true}, - {"see L385 for details", true}, - {"char pos #4096", true}, - {"no address here", false}, - } - for _, tc := range cases { - if got := containsLineRef(tc.s); got != tc.want { - t.Errorf("containsLineRef(%q) = %v, want %v", tc.s, got, tc.want) - } - } -} - -func TestContainsAcmeRegexAddr(t *testing.T) { - cases := []struct { - s string - want bool - }{ - {"beads.go:/lintDescription/", true}, - {"see /funcName/ in the file", true}, - {"no slashes", false}, - {"a/b/c directory path", false}, // single-char tokens, below 4-char minimum - {"internal/p9/beads.go", false}, // short path components (/p9/ = 2 chars) - {"internal/pkg/beads.go", false}, // /pkg/ = 3 chars, below minimum - {"see /handleNewCommand/ here", true}, // 16 chars >= 4 - } - for _, tc := range cases { - if got := containsAcmeRegexAddr(tc.s); got != tc.want { - t.Errorf("containsAcmeRegexAddr(%q) = %v, want %v", tc.s, got, tc.want) - } - } -} - -func TestContainsHyphenRange(t *testing.T) { - cases := []struct { - s string - want bool - }{ - {"beads.go:123-125", true}, - {"beads.go:123,125", false}, - {"beads.go:123", false}, - {"version 1-2", false}, // no colon before - } - for _, tc := range cases { - if got := containsHyphenRange(tc.s); got != tc.want { - t.Errorf("containsHyphenRange(%q) = %v, want %v", tc.s, got, tc.want) - } - } -} - -func TestContainsBdRef(t *testing.T) { - cases := []struct { - s string - want bool - }{ - {"see bd-abc for context", true}, - {"bd-9y4 and bd-iti", true}, - {"bd- no alphanumeric", false}, - {"no reference here", false}, - } - for _, tc := range cases { - if got := containsBdRef(tc.s); got != tc.want { - t.Errorf("containsBdRef(%q) = %v, want %v", tc.s, got, tc.want) - } - } -} diff --git a/internal/p9/beads_mount_test.go b/internal/p9/beads_mount_test.go deleted file mode 100644 index eb0cd05..0000000 --- a/internal/p9/beads_mount_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package p9 - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - bd "github.com/steveyegge/beads" -) - -func TestMountUnmount(t *testing.T) { - tmpDir := t.TempDir() - os.Setenv("HOME", tmpDir) - defer os.Unsetenv("HOME") - - store, err := bd.OpenFromConfig(context.Background(), filepath.Join(tmpDir, "main")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - projectDir := filepath.Join(tmpDir, "project1") - os.MkdirAll(projectDir, 0755) - - err = beadsFS.Mount("proj1", projectDir) - if err != nil { - t.Fatalf("Mount failed: %v", err) - } - - mounts := beadsFS.ListMounts() - if _, ok := mounts["proj1"]; !ok { - t.Fatal("Mount not listed") - } - - err = beadsFS.Umount("proj1") - if err != nil { - t.Fatalf("Umount failed: %v", err) - } - - mounts = beadsFS.ListMounts() - if _, ok := mounts["proj1"]; ok { - t.Fatal("Mount still listed after umount") - } -} - -func TestMountReadWrite(t *testing.T) { - tmpDir := t.TempDir() - os.Setenv("HOME", tmpDir) - defer os.Unsetenv("HOME") - - store, err := bd.OpenFromConfig(context.Background(), filepath.Join(tmpDir, "main")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - projectDir := filepath.Join(tmpDir, "project1") - os.MkdirAll(projectDir, 0755) - - err = beadsFS.Mount("proj1", projectDir) - if err != nil { - t.Fatal(err) - } - defer beadsFS.Umount("proj1") - - err = beadsFS.Write("proj1/ctl", []byte("init bd"), "test") - if err != nil { - t.Fatal(err) - } - - err = beadsFS.Write("proj1/ctl", []byte("new 'Test Task' 'Description'"), "test") - if err != nil { - t.Fatal(err) - } - - list, err := beadsFS.Read("proj1/list") - if err != nil { - t.Fatal(err) - } - - var beads []map[string]interface{} - json.Unmarshal(list, &beads) - if len(beads) != 1 { - t.Fatalf("Expected 1 bead, got %d", len(beads)) - } - - beadID := beads[0]["id"].(string) - err = beadsFS.Write("proj1/ctl", []byte("label "+beadID+" claimable"), "test") - if err != nil { - t.Fatal(err) - } - - ready, err := beadsFS.Read("proj1/ready") - if err != nil { - t.Fatal(err) - } - - var readyBeads []map[string]interface{} - json.Unmarshal(ready, &readyBeads) - if len(readyBeads) != 1 { - t.Fatalf("Expected 1 ready bead, got %d", len(readyBeads)) - } -} - -func TestReadyAggregate(t *testing.T) { - tmpDir := t.TempDir() - os.Setenv("HOME", tmpDir) - defer os.Unsetenv("HOME") - - store, err := bd.OpenFromConfig(context.Background(), filepath.Join(tmpDir, "main")) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - proj1 := filepath.Join(tmpDir, "project1") - proj2 := filepath.Join(tmpDir, "project2") - os.MkdirAll(proj1, 0755) - os.MkdirAll(proj2, 0755) - - beadsFS.Mount("proj1", proj1) - beadsFS.Mount("proj2", proj2) - defer beadsFS.Umount("proj1") - defer beadsFS.Umount("proj2") - - beadsFS.Write("proj1/ctl", []byte("init bd"), "test") - beadsFS.Write("proj2/ctl", []byte("init bd"), "test") - - beadsFS.Write("proj1/ctl", []byte("new 'Task 1' 'Desc 1'"), "test") - beadsFS.Write("proj2/ctl", []byte("new 'Task 2' 'Desc 2'"), "test") - - ready, err := beadsFS.Read("ready") - if err != nil { - t.Fatal(err) - } - - var tasks []map[string]interface{} - json.Unmarshal(ready, &tasks) - - proj1Count := 0 - proj2Count := 0 - for _, task := range tasks { - mount, _ := task["mount"].(string) - if mount == "proj1" { - proj1Count++ - } else if mount == "proj2" { - proj2Count++ - } - } - - if proj1Count != 1 || proj2Count != 1 { - t.Fatalf("Expected 1 task from each mount, got proj1=%d proj2=%d", proj1Count, proj2Count) - } -} diff --git a/internal/p9/beads_parse_test.go b/internal/p9/beads_parse_test.go deleted file mode 100644 index abfd67c..0000000 --- a/internal/p9/beads_parse_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package p9 - -import ( - "testing" -) - -func TestParseCtlCommand(t *testing.T) { - tests := []struct { - name string - input string - wantCmd string - wantArgs []string - wantErr bool - }{ - { - name: "empty command", - input: "", - wantErr: true, - }, - { - name: "whitespace only", - input: " ", - wantErr: true, - }, - { - name: "command only", - input: "claim", - wantCmd: "claim", - wantArgs: []string{}, - }, - { - name: "command with one arg", - input: "claim bd-123", - wantCmd: "claim", - wantArgs: []string{"bd-123"}, - }, - { - name: "command with multiple args", - input: "new title description parent", - wantCmd: "new", - wantArgs: []string{"title", "description", "parent"}, - }, - { - name: "single quoted arg", - input: "new 'my title'", - wantCmd: "new", - wantArgs: []string{"my title"}, - }, - { - name: "double quoted arg", - input: `new "my title"`, - wantCmd: "new", - wantArgs: []string{"my title"}, - }, - { - name: "mixed quoted and unquoted", - input: "new 'my title' description bd-parent", - wantCmd: "new", - wantArgs: []string{"my title", "description", "bd-parent"}, - }, - { - name: "empty quoted string", - input: "comment bd-123 ''", - wantCmd: "comment", - wantArgs: []string{"bd-123", ""}, - }, - { - name: "multiple spaces", - input: "claim bd-123", - wantCmd: "claim", - wantArgs: []string{"bd-123"}, - }, - { - name: "tabs and spaces", - input: "claim\t\tbd-123", - wantCmd: "claim", - wantArgs: []string{"bd-123"}, - }, - { - name: "quoted arg with special chars", - input: "comment bd-123 'Work in progress: 50% done'", - wantCmd: "comment", - wantArgs: []string{"bd-123", "Work in progress: 50% done"}, - }, - { - name: "nested quotes not supported", - input: `new "title with 'nested' quotes"`, - wantCmd: "new", - wantArgs: []string{"title with ", "nested", " quotes"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd, args, err := parseCtlCommand(tt.input) - - if tt.wantErr { - if err == nil { - t.Errorf("expected error, got none") - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if cmd != tt.wantCmd { - t.Errorf("command = %q, want %q", cmd, tt.wantCmd) - } - - if len(args) != len(tt.wantArgs) { - t.Errorf("args length = %d, want %d\nargs = %v\nwant = %v", - len(args), len(tt.wantArgs), args, tt.wantArgs) - return - } - - for i := range args { - if args[i] != tt.wantArgs[i] { - t.Errorf("args[%d] = %q, want %q", i, args[i], tt.wantArgs[i]) - } - } - }) - } -} diff --git a/internal/p9/beads_test.go b/internal/p9/beads_test.go deleted file mode 100644 index a77a035..0000000 --- a/internal/p9/beads_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package p9 - -import ( - "context" - "os" - "path/filepath" - "testing" - - bd "github.com/steveyegge/beads" -) - -func TestBeadsFS_Mount(t *testing.T) { - tmpDir := t.TempDir() - store, err := bd.OpenFromConfig(context.Background(), tmpDir) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - testCwd := filepath.Join(tmpDir, "project") - os.MkdirAll(filepath.Join(testCwd, ".beads"), 0755) - - // Test mount - err = beadsFS.Mount("test-project", testCwd) - if err != nil { - t.Fatalf("Mount failed: %v", err) - } - - // Test duplicate mount - err = beadsFS.Mount("test-project", testCwd) - if err == nil { - t.Fatal("Should have failed on duplicate mount") - } -} - -func TestBeadsFS_Umount(t *testing.T) { - tmpDir := t.TempDir() - store, err := bd.OpenFromConfig(context.Background(), tmpDir) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - testCwd := filepath.Join(tmpDir, "project") - os.MkdirAll(filepath.Join(testCwd, ".beads"), 0755) - - // Mount first - err = beadsFS.Mount("test-project", testCwd) - if err != nil { - t.Fatalf("Mount failed: %v", err) - } - - // Test umount - err = beadsFS.Umount("test-project") - if err != nil { - t.Fatalf("Umount failed: %v", err) - } - - // Test umount non-existent - err = beadsFS.Umount("nonexistent") - if err == nil { - t.Fatal("Should have failed on non-existent mount") - } -} - -func TestBeadsFS_MountRouting(t *testing.T) { - tmpDir := t.TempDir() - store, err := bd.OpenFromConfig(context.Background(), tmpDir) - if err != nil { - t.Fatalf("Failed to create store: %v", err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - testCwd := filepath.Join(tmpDir, "project") - os.MkdirAll(filepath.Join(testCwd, ".beads"), 0755) - - // Mount - err = beadsFS.Mount("test-project", testCwd) - if err != nil { - t.Fatalf("Mount failed: %v", err) - } - defer beadsFS.Umount("test-project") - - // Test cwd endpoint - data, err := beadsFS.Read("anvillm/beads/test-project/cwd") - if err != nil { - t.Fatalf("Read cwd failed: %v", err) - } - if string(data) != testCwd { - t.Fatalf("Expected cwd %s, got %s", testCwd, string(data)) - } - - // Test non-existent mount - _, err = beadsFS.Read("anvillm/beads/nonexistent/cwd") - if err == nil { - t.Fatal("Should have failed on non-existent mount") - } -} diff --git a/internal/p9/label_test.go b/internal/p9/label_test.go deleted file mode 100644 index 2805e46..0000000 --- a/internal/p9/label_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package p9 - -import ( - "context" - "encoding/json" - "fmt" - "testing" - - bd "github.com/steveyegge/beads" -) - -func TestLabelClaimable(t *testing.T) { - tmpDir := t.TempDir() - - store, err := bd.OpenFromConfig(context.Background(), tmpDir) - if err != nil { - t.Fatal(err) - } - defer store.Close() - - beadsFS := NewBeadsFS(store, context.Background()) - - // Initialize - err = beadsFS.Write("ctl", []byte("init bd"), "test") - if err != nil { - t.Fatal(err) - } - - // Create a bead - err = beadsFS.Write("ctl", []byte("new 'Test Task' 'Test description'"), "test") - if err != nil { - t.Fatal(err) - } - - // List beads to get ID - list, err := beadsFS.Read("list") - if err != nil { - t.Fatal(err) - } - - var beads []map[string]interface{} - json.Unmarshal(list, &beads) - if len(beads) == 0 { - t.Fatal("no beads created") - } - - beadID := beads[0]["id"].(string) - t.Logf("Created bead: %s", beadID) - - // Label it as claimable - cmd := fmt.Sprintf("label %s claimable", beadID) - t.Logf("Running command: %s", cmd) - err = beadsFS.Write("ctl", []byte(cmd), "test") - if err != nil { - t.Fatalf("label failed: %v", err) - } - t.Logf("Label command succeeded") - - // Read it back - list, err = beadsFS.Read("list") - if err != nil { - t.Fatal(err) - } - - json.Unmarshal(list, &beads) - t.Logf("Full bead data: %+v", beads[0]) - labels, ok := beads[0]["labels"].([]interface{}) - if !ok { - labels = []interface{}{} - } - t.Logf("Labels: %v", labels) - - hasClaimable := false - for _, l := range labels { - if l.(string) == "claimable" { - hasClaimable = true - } - } - - if !hasClaimable { - t.Fatal("claimable label not found") - } -} diff --git a/internal/p9/server.go b/internal/p9/server.go index b8cb8a8..2c63c01 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -9,7 +9,6 @@ import ( "anvillm/internal/mailbox" "anvillm/internal/session" "context" - "encoding/json" "errors" "fmt" "io" @@ -77,12 +76,6 @@ const ( qidUserCompleted // user/completed qidUserCtl // user/ctl qidUserMail // user/mail - qidBeads // beads directory - qidBeadsCtl // beads/ctl - qidBeadsList // beads/list - qidBeadsMtab // beads/mtab - qidBeadsReady // beads/ready - qidBeadsDeferred // beads/deferred qidTools // tools directory qidSkills // skills directory qidRoles // roles directory @@ -92,7 +85,6 @@ const ( qidOutboxBase = 0x30000000 // session/{id}/outbox qidCompletedBase = 0x40000000 // session/{id}/completed qidMessageBase = 0x50000000 // message files - qidBeadsBase = 0x60000000 // beads/{id}/file qidToolsBase = 0x70000000 // tools/{tool} qidSkillsBase = 0x80000000 // skills/{skill} qidRolesBase = 0x90000000 // roles/{role} @@ -127,7 +119,6 @@ type Server struct { listener net.Listener socketPath string events *eventbus.Bus - beads *BeadsFS tools *ToolsFS skills *SkillsFS roles *RolesFS @@ -156,7 +147,7 @@ type fid struct { } // NewServer creates and starts the 9P server. -func NewServer(mgr *session.Manager, beadsFS *BeadsFS) (*Server, error) { +func NewServer(mgr *session.Manager) (*Server, error) { ns := client.Namespace() if ns == "" { return nil, fmt.Errorf("no namespace") @@ -188,7 +179,6 @@ func NewServer(mgr *session.Manager, beadsFS *BeadsFS) (*Server, error) { listener: listener, socketPath: sockPath, events: eventbus.New(), - beads: beadsFS, tools: toolsFS, skills: NewSkillsFS(), roles: NewRolesFS(), @@ -228,11 +218,6 @@ func (s *Server) Events() *eventbus.Bus { return s.events } -// Beads returns the BeadsFS instance. -func (s *Server) Beads() *BeadsFS { - return s.beads -} - func (s *Server) acceptLoop() { for { conn, err := s.listener.Accept() @@ -366,9 +351,6 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { case "user": qid = plan9.Qid{Type: QTDir, Path: qidUser} newPath = "/user" - case "beads": - qid = plan9.Qid{Type: QTDir, Path: qidBeads} - newPath = "/beads" case "tools": qid = plan9.Qid{Type: QTDir, Path: qidTools} newPath = "/tools" @@ -408,38 +390,6 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { default: return errFcall(fc, "not found") } - } else if path == "/beads" { - // Inside beads directory - switch name { - case "ctl": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsCtl} - newPath = "/beads/ctl" - case "list": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsList} - newPath = "/beads/list" - case "mtab": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsMtab} - newPath = "/beads/mtab" - case "ready": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsReady} - newPath = "/beads/ready" - case "deferred": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsDeferred} - newPath = "/beads/deferred" - default: - // Mount directory - verify it exists - if s.beads != nil { - mounts := s.beads.ListMounts() - if _, isMount := mounts[name]; isMount { - qid = plan9.Qid{Type: QTDir, Path: qidBeadsBase + hashID(name)} - newPath = "/beads/" + name - } else { - return errFcall(fc, "not found") - } - } else { - return errFcall(fc, "not found") - } - } } else if path == "/tools" { // Inside tools directory - list tools flat if s.tools != nil { @@ -497,32 +447,6 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { } else { return errFcall(fc, "not found") } - } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 { - // Inside a mount directory - mountName := strings.TrimPrefix(path, "/beads/") - if s.beads != nil { - mounts := s.beads.ListMounts() - if _, isMount := mounts[mountName]; isMount { - // Check if name is a control file or bead ID - switch name { - case "cwd", "ctl", "list", "ready", "deferred", "stats", "blocked", "stale", "query", "config": - qid = plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+name)} - newPath = path + "/" + name - default: - // Bead ID directory - qid = plan9.Qid{Type: QTDir, Path: qidBeadsBase + hashID(mountName+name)} - newPath = path + "/" + name - } - } else { - return errFcall(fc, "not found") - } - } else { - return errFcall(fc, "not found") - } - } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 3 { - // Inside a mount's bead directory - property files - qid = plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+name)} - newPath = path + "/" + name } else if strings.HasPrefix(path, "/user/") && strings.Count(path, "/") == 2 { // Inside user mailbox directory - message files qid = plan9.Qid{Type: QTFile, Path: qidMessageBase + hashID("user"+path[6:]+name)} @@ -677,20 +601,6 @@ func (s *Server) dispatchWrite(cs *connState, f *fid, tag uint16) *plan9.Fcall { input := strings.TrimSpace(string(f.writeBuf)) parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - // Handle beads writes - if beadsPath, ok := strings.CutPrefix(path, "/beads/"); ok { - if s.beads != nil { - cs.mu.Lock() - sessionID := cs.sessionID - cs.mu.Unlock() - if err := s.beads.Write(beadsPath, f.writeBuf, sessionID); err != nil { - return errFcall(fc, err.Error()) - } - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(f.writeBuf))} - } - return errFcall(fc, "beads not initialized") - } - // /ctl - create new session or recover orphaned sessions if path == "/ctl" { args := strings.Fields(input) @@ -1084,10 +994,6 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { Qid: plan9.Qid{Type: QTDir, Path: qidUser}, Mode: plan9.DMDIR | 0555, Name: "user", Uid: "q", Gid: "q", Muid: "q", }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidBeads}, - Mode: plan9.DMDIR | 0555, Name: "beads", Uid: "q", Gid: "q", Muid: "q", - }) dirs = append(dirs, plan9.Dir{ Qid: plan9.Qid{Type: QTDir, Path: qidTools}, Mode: plan9.DMDIR | 0555, Name: "tools", Uid: "q", Gid: "q", Muid: "q", @@ -1139,119 +1045,6 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { }) } } - } else if path == "/beads" { - // Beads directory - only ctl, mtab, ready (aggregate), and mounted projects - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsCtl}, Mode: 0222, Name: "ctl", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsMtab}, Mode: 0444, Name: "mtab", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsReady}, Mode: 0444, Name: "ready", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsDeferred}, Mode: 0444, Name: "deferred", - Uid: "q", Gid: "q", Muid: "q", - }) - // Add mounted projects as directories - if s.beads != nil { - for name := range s.beads.ListMounts() { - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidBeadsBase + uint64(len(name))}, Mode: 0555 | plan9.DMDIR, Name: name, - Uid: "q", Gid: "q", Muid: "q", - }) - } - } - } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 { - // Inside a mounted project directory - show all beads endpoints - mountName := strings.TrimPrefix(path, "/beads/") - if s.beads != nil { - mounts := s.beads.ListMounts() - if _, exists := mounts[mountName]; exists { - // Show all standard beads endpoints for this mount - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"cwd")}, Mode: 0444, Name: "cwd", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"ctl")}, Mode: 0222, Name: "ctl", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"list")}, Mode: 0444, Name: "list", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"ready")}, Mode: 0444, Name: "ready", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"deferred")}, Mode: 0444, Name: "deferred", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"stats")}, Mode: 0444, Name: "stats", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"blocked")}, Mode: 0444, Name: "blocked", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"stale")}, Mode: 0444, Name: "stale", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"query")}, Mode: 0644, Name: "query", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(mountName+"config")}, Mode: 0444, Name: "config", - Uid: "q", Gid: "q", Muid: "q", - }) - // Add bead directories - data, err := s.beads.Read(mountName + "/list") - if err == nil { - var issues []struct{ ID string `json:"id"` } - if json.Unmarshal(data, &issues) == nil { - for _, issue := range issues { - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidBeadsBase + hashID(mountName+issue.ID)}, Mode: 0555 | plan9.DMDIR, Name: issue.ID, - Uid: "q", Gid: "q", Muid: "q", - }) - } - } - } - } - } - } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 3 { - // Inside a mount's bead directory - show property files - // e.g., /beads/anvillm/anv-77c/json - // Do NOT show the bead ID itself as a subdirectory - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+"json")}, Mode: 0444, Name: "json", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+"status")}, Mode: 0444, Name: "status", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+"title")}, Mode: 0444, Name: "title", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+"description")}, Mode: 0444, Name: "description", - Uid: "q", Gid: "q", Muid: "q", - }) - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidBeadsBase + hashID(path+"assignee")}, Mode: 0444, Name: "assignee", - Uid: "q", Gid: "q", Muid: "q", - }) } else if path == "/user" { // User directory - only mailbox subdirs dirs = append(dirs, plan9.Dir{ @@ -1439,18 +1232,6 @@ func (s *Server) readFile(path string) string { return "" } - // Handle beads paths - if beadsPath, ok := strings.CutPrefix(path, "/beads/"); ok { - if s.beads != nil { - data, err := s.beads.Read(beadsPath) - if err != nil { - return "" - } - return string(data) - } - return "" - } - if path == "/list" { var lines []string for _, id := range s.mgr.List() { diff --git a/mcptools/add_dependency.sh b/mcptools/add_dependency.sh index 8278f65..1836bca 100755 --- a/mcptools/add_dependency.sh +++ b/mcptools/add_dependency.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$CHILD" ] || [ -z "$PARENT" ]; then exit 1 fi -echo "dep $CHILD $PARENT" | 9p write anvillm/beads/$MOUNT/ctl +echo "dep $CHILD $PARENT" | 9p write beads/$MOUNT/ctl echo "$CHILD depends on $PARENT" diff --git a/mcptools/batch_create_beads.sh b/mcptools/batch_create_beads.sh index ec5868e..c7ebbee 100755 --- a/mcptools/batch_create_beads.sh +++ b/mcptools/batch_create_beads.sh @@ -22,7 +22,7 @@ if [ -z "$MOUNT" ] || [ -z "$JSON" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -35,5 +35,5 @@ if [ -n "$SCOPE" ]; then JSON=$(echo "$JSON" | jq --arg scope "$SCOPE" '[.[] | . + {scope: $scope}]') fi -echo "batch-create $JSON" | 9p write "anvillm/beads/$MOUNT/ctl" +echo "batch-create $JSON" | 9p write "beads/$MOUNT/ctl" echo "batch created beads" diff --git a/mcptools/claim_bead.sh b/mcptools/claim_bead.sh index 71d0aac..6a2b033 100755 --- a/mcptools/claim_bead.sh +++ b/mcptools/claim_bead.sh @@ -26,9 +26,9 @@ if [ -z "${AGENT_ID:-}" ]; then exit 1 fi -echo "claim $BEAD_ID $AGENT_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "claim $BEAD_ID $AGENT_ID" | 9p write beads/$MOUNT/ctl -assignee=$(9p read anvillm/beads/$MOUNT/$BEAD_ID/json 2>/dev/null | jq -r '.assignee // empty') +assignee=$(9p read beads/$MOUNT/$BEAD_ID/json 2>/dev/null | jq -r '.assignee // empty') if [ "$assignee" != "$AGENT_ID" ]; then echo "error: claim failed (bead is assigned to '$assignee')" >&2 exit 1 diff --git a/mcptools/comment_bead.sh b/mcptools/comment_bead.sh index 4d2d333..7fe4676 100755 --- a/mcptools/comment_bead.sh +++ b/mcptools/comment_bead.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$TEXT" ]; then exit 1 fi -printf "comment %s '%s'\n" "$BEAD_ID" "$TEXT" | 9p write anvillm/beads/$MOUNT/ctl +printf "comment %s '%s'\n" "$BEAD_ID" "$TEXT" | 9p write beads/$MOUNT/ctl echo "commented on $BEAD_ID" diff --git a/mcptools/complete_bead.sh b/mcptools/complete_bead.sh index 023a104..dc087bf 100755 --- a/mcptools/complete_bead.sh +++ b/mcptools/complete_bead.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -echo "complete $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "complete $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "completed $BEAD_ID" diff --git a/mcptools/config_beads.sh b/mcptools/config_beads.sh index 6e26926..b18a201 100755 --- a/mcptools/config_beads.sh +++ b/mcptools/config_beads.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$KEY" ]; then exit 1 fi -echo "config $KEY $VALUE" | 9p write anvillm/beads/$MOUNT/ctl +echo "config $KEY $VALUE" | 9p write beads/$MOUNT/ctl echo "config $KEY = $VALUE" diff --git a/mcptools/create_bead.sh b/mcptools/create_bead.sh index 5ae7ec0..8f00aad 100755 --- a/mcptools/create_bead.sh +++ b/mcptools/create_bead.sh @@ -30,7 +30,7 @@ if [ -z "$MOUNT" ] || [ -z "$TITLE" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -44,5 +44,5 @@ CMD="new '$TITLE' '$DESC'" [ -n "$CAP" ] && CMD="$CMD capability=$CAP" [ -n "$SCOPE" ] && CMD="$CMD scope=$SCOPE" -echo "$CMD" | 9p write anvillm/beads/$MOUNT/ctl +echo "$CMD" | 9p write beads/$MOUNT/ctl echo "created (deferred): $TITLE" diff --git a/mcptools/defer_bead.sh b/mcptools/defer_bead.sh index f82536a..1ec85f2 100755 --- a/mcptools/defer_bead.sh +++ b/mcptools/defer_bead.sh @@ -24,9 +24,9 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then fi if [ -n "$UNTIL" ]; then - echo "defer $BEAD_ID until $UNTIL" | 9p write anvillm/beads/$MOUNT/ctl + echo "defer $BEAD_ID until $UNTIL" | 9p write beads/$MOUNT/ctl echo "deferred $BEAD_ID until $UNTIL" else - echo "defer $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl + echo "defer $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "deferred $BEAD_ID" fi diff --git a/mcptools/delete_bead.sh b/mcptools/delete_bead.sh index 9bf9788..21c3f64 100755 --- a/mcptools/delete_bead.sh +++ b/mcptools/delete_bead.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -echo "delete $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "delete $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "deleted $BEAD_ID" diff --git a/mcptools/fail_bead.sh b/mcptools/fail_bead.sh index aa9194a..ffc932c 100755 --- a/mcptools/fail_bead.sh +++ b/mcptools/fail_bead.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$REASON" ]; then exit 1 fi -printf "fail %s '%s'\n" "$BEAD_ID" "$REASON" | 9p write anvillm/beads/$MOUNT/ctl +printf "fail %s '%s'\n" "$BEAD_ID" "$REASON" | 9p write beads/$MOUNT/ctl echo "failed $BEAD_ID: $REASON" diff --git a/mcptools/github_to_beads.sh b/mcptools/github_to_beads.sh index 62b6eac..4901c50 100755 --- a/mcptools/github_to_beads.sh +++ b/mcptools/github_to_beads.sh @@ -23,7 +23,7 @@ if [ -z "$MOUNT" ] || [ -z "$REPO" ] || [ -z "$ISSUE" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -34,7 +34,7 @@ SCOPE_ARG="" [ -n "$SCOPE" ] && SCOPE_ARG="scope=$SCOPE" # Check if already imported -if 9p read "anvillm/beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"#$ISSUE\"))" >/dev/null 2>&1; then +if 9p read "beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"#$ISSUE\"))" >/dev/null 2>&1; then echo "Issue #$ISSUE already imported" >&2 exit 0 fi @@ -64,19 +64,19 @@ fi bead_title="#$number: $title" # Create parent bead -echo "new \"$bead_title\" \"$body\" '' $SCOPE_ARG" | 9p write "anvillm/beads/$MOUNT/ctl" +echo "new \"$bead_title\" \"$body\" '' $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" # Get created bead ID -bead_id=$(9p read "anvillm/beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"#$number\")) | .id" | head -1) +bead_id=$(9p read "beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"#$number\")) | .id" | head -1) # Update issue type if not task if [[ "$issue_type" != "task" ]]; then - echo "update $bead_id issue_type $issue_type" | 9p write "anvillm/beads/$MOUNT/ctl" + echo "update $bead_id issue_type $issue_type" | 9p write "beads/$MOUNT/ctl" fi # Update status if closed if [[ "$status" == "closed" ]]; then - echo "update $bead_id status closed" | 9p write "anvillm/beads/$MOUNT/ctl" + echo "update $bead_id status closed" | 9p write "beads/$MOUNT/ctl" fi # Parse task list from body @@ -84,7 +84,7 @@ if [[ -n "$body" ]]; then # Extract unchecked tasks: - [ ] Task name echo "$body" | grep -E '^\s*-\s+\[\s+\]' | sed -E 's/^\s*-\s+\[\s+\]\s*//' | while IFS= read -r task; do if [[ -n "$task" ]]; then - echo "new \"$task\" \"\" $bead_id $SCOPE_ARG" | 9p write "anvillm/beads/$MOUNT/ctl" + echo "new \"$task\" \"\" $bead_id $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" fi done fi diff --git a/mcptools/init_beads.sh b/mcptools/init_beads.sh index 9d5c333..8c8b4e5 100755 --- a/mcptools/init_beads.sh +++ b/mcptools/init_beads.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ]; then exit 1 fi -echo "init $PREFIX" | 9p write "anvillm/beads/$MOUNT/ctl" +echo "init $PREFIX" | 9p write "beads/$MOUNT/ctl" echo "initialized $MOUNT (prefix: $PREFIX)" diff --git a/mcptools/jira_to_beads.sh b/mcptools/jira_to_beads.sh index e11f9ec..531252b 100755 --- a/mcptools/jira_to_beads.sh +++ b/mcptools/jira_to_beads.sh @@ -21,7 +21,7 @@ if [ -z "$MOUNT" ] || [ -z "$TICKET" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -32,7 +32,7 @@ SCOPE_ARG="" [ -n "$SCOPE" ] && SCOPE_ARG="scope=$SCOPE" # Check if already imported -if 9p read "anvillm/beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"$TICKET\"))" >/dev/null 2>&1; then +if 9p read "beads/$MOUNT/list" | jq -e ".[] | select(.title | contains(\"$TICKET\"))" >/dev/null 2>&1; then echo "Ticket $TICKET already imported" >&2 exit 0 fi @@ -69,14 +69,14 @@ create_bead() { # Create bead if [[ -n "$parent_bead" ]]; then - echo "new \"$title\" \"$description\" $parent_bead $SCOPE_ARG" | 9p write "anvillm/beads/$MOUNT/ctl" + echo "new \"$title\" \"$description\" $parent_bead $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" else - echo "new \"$title\" \"$description\" '' $SCOPE_ARG" | 9p write "anvillm/beads/$MOUNT/ctl" + echo "new \"$title\" \"$description\" '' $SCOPE_ARG" | 9p write "beads/$MOUNT/ctl" fi # Get created bead ID local bead_id - bead_id=$(9p read "anvillm/beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"$key\")) | .id" | head -1) + bead_id=$(9p read "beads/$MOUNT/list" | jq -r ".[] | select(.title | contains(\"$key\")) | .id" | head -1) # Process children local children diff --git a/mcptools/label_bead.sh b/mcptools/label_bead.sh index 582b33d..5c8b483 100755 --- a/mcptools/label_bead.sh +++ b/mcptools/label_bead.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LABEL" ]; then exit 1 fi -echo "label $BEAD_ID $LABEL" | 9p write anvillm/beads/$MOUNT/ctl +echo "label $BEAD_ID $LABEL" | 9p write beads/$MOUNT/ctl echo "labeled $BEAD_ID: $LABEL" diff --git a/mcptools/list_beads.sh b/mcptools/list_beads.sh index 6ef45d5..5aada07 100755 --- a/mcptools/list_beads.sh +++ b/mcptools/list_beads.sh @@ -20,7 +20,7 @@ if [ -z "$MOUNT" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) MY_SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -29,4 +29,4 @@ if [ -n "$MOUNT_CWD" ]; then fi # Strict match: scope must equal (both empty or both same value) -9p read "anvillm/beads/$MOUNT/list" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" +9p read "beads/$MOUNT/list" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" diff --git a/mcptools/list_mounts.sh b/mcptools/list_mounts.sh index 4b6bd20..fa119c7 100755 --- a/mcptools/list_mounts.sh +++ b/mcptools/list_mounts.sh @@ -4,4 +4,4 @@ set -euo pipefail -9p read anvillm/beads/mtab 2>/dev/null || echo "No mounted projects" +9p read beads/mtab 2>/dev/null || echo "No mounted projects" diff --git a/mcptools/list_ready_beads.sh b/mcptools/list_ready_beads.sh index 3bf2964..50713e3 100755 --- a/mcptools/list_ready_beads.sh +++ b/mcptools/list_ready_beads.sh @@ -20,7 +20,7 @@ if [ -z "$MOUNT" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) MY_SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -29,4 +29,4 @@ if [ -n "$MOUNT_CWD" ]; then fi # Strict match: scope must equal (both empty or both same value) -9p read "anvillm/beads/$MOUNT/ready" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" +9p read "beads/$MOUNT/ready" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, priority, title, status, scope}]' || echo "[]" diff --git a/mcptools/mount_beads.sh b/mcptools/mount_beads.sh index 9dc0736..1883def 100755 --- a/mcptools/mount_beads.sh +++ b/mcptools/mount_beads.sh @@ -22,10 +22,10 @@ fi MOUNT=$(uuidgen | cut -d- -f1) -echo "mount $CWD $MOUNT" | 9p write anvillm/beads/ctl +echo "mount $CWD $MOUNT" | 9p write beads/ctl # Verify mount succeeded -if ! 9p read anvillm/beads/mtab 2>/dev/null | grep -q "^$MOUNT "; then +if ! 9p read beads/mtab 2>/dev/null | grep -q "^$MOUNT "; then echo "error: mount failed for $CWD" >&2 exit 1 fi diff --git a/mcptools/open_bead.sh b/mcptools/open_bead.sh index 2403877..5734afb 100755 --- a/mcptools/open_bead.sh +++ b/mcptools/open_bead.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -echo "open $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "open $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "opened $BEAD_ID" diff --git a/mcptools/read_bead.sh b/mcptools/read_bead.sh index f080c23..67e3496 100755 --- a/mcptools/read_bead.sh +++ b/mcptools/read_bead.sh @@ -24,4 +24,4 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$PROPERTY" ]; then exit 1 fi -9p read "anvillm/beads/$MOUNT/$BEAD_ID/$PROPERTY" 2>/dev/null || echo "Property not found: $PROPERTY" +9p read "beads/$MOUNT/$BEAD_ID/$PROPERTY" 2>/dev/null || echo "Property not found: $PROPERTY" diff --git a/mcptools/read_bead_comments.sh b/mcptools/read_bead_comments.sh index 049514e..b5ea5ce 100755 --- a/mcptools/read_bead_comments.sh +++ b/mcptools/read_bead_comments.sh @@ -21,4 +21,4 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -9p read "anvillm/beads/$MOUNT/$BEAD_ID/comments" 2>/dev/null || echo "[]" +9p read "beads/$MOUNT/$BEAD_ID/comments" 2>/dev/null || echo "[]" diff --git a/mcptools/relate_beads.sh b/mcptools/relate_beads.sh index 5b1203b..f1b2668 100755 --- a/mcptools/relate_beads.sh +++ b/mcptools/relate_beads.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD1" ] || [ -z "$BEAD2" ]; then exit 1 fi -echo "relate $BEAD1 $BEAD2" | 9p write anvillm/beads/$MOUNT/ctl +echo "relate $BEAD1 $BEAD2" | 9p write beads/$MOUNT/ctl echo "related $BEAD1 ↔ $BEAD2" diff --git a/mcptools/remove_dependency.sh b/mcptools/remove_dependency.sh index 16fc4bd..0325839 100755 --- a/mcptools/remove_dependency.sh +++ b/mcptools/remove_dependency.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$CHILD" ] || [ -z "$PARENT" ]; then exit 1 fi -echo "undep $CHILD $PARENT" | 9p write anvillm/beads/$MOUNT/ctl +echo "undep $CHILD $PARENT" | 9p write beads/$MOUNT/ctl echo "$CHILD no longer depends on $PARENT" diff --git a/mcptools/reopen_bead.sh b/mcptools/reopen_bead.sh index f064b59..60ea79d 100755 --- a/mcptools/reopen_bead.sh +++ b/mcptools/reopen_bead.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -echo "reopen $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "reopen $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "reopened $BEAD_ID" diff --git a/mcptools/search_beads.sh b/mcptools/search_beads.sh index 3b0c64e..db10c8b 100755 --- a/mcptools/search_beads.sh +++ b/mcptools/search_beads.sh @@ -27,7 +27,7 @@ fi # Direct ID lookup if [ -n "$BEAD_ID" ]; then - 9p read "anvillm/beads/$MOUNT/$BEAD_ID/json" 2>/dev/null || echo "null" + 9p read "beads/$MOUNT/$BEAD_ID/json" 2>/dev/null || echo "null" exit 0 fi @@ -38,7 +38,7 @@ if [ -z "$QUERY" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) MY_SCOPE="" if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" @@ -46,4 +46,4 @@ if [ -n "$MOUNT_CWD" ]; then MY_SCOPE="${REL_PATH%%/*}" fi -9p read "anvillm/beads/$MOUNT/search/$QUERY" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, title, status, scope, match_in: (if .id | test("'"$QUERY"'"; "i") then "id" elif .title | test("'"$QUERY"'"; "i") then "title" else "description" end)}]' || echo "[]" +9p read "beads/$MOUNT/search/$QUERY" 2>/dev/null | jq --arg scope "$MY_SCOPE" '[.[] | select((.scope // "") == $scope) | {id, title, status, scope, match_in: (if .id | test("'"$QUERY"'"; "i") then "id" elif .title | test("'"$QUERY"'"; "i") then "title" else "description" end)}]' || echo "[]" diff --git a/mcptools/set_capability.sh b/mcptools/set_capability.sh index fbf73ac..273d8a9 100755 --- a/mcptools/set_capability.sh +++ b/mcptools/set_capability.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LEVEL" ]; then exit 1 fi -echo "set-capability $BEAD_ID $LEVEL" | 9p write anvillm/beads/$MOUNT/ctl +echo "set-capability $BEAD_ID $LEVEL" | 9p write beads/$MOUNT/ctl echo "capability set: $BEAD_ID → $LEVEL" diff --git a/mcptools/sync_beads.sh b/mcptools/sync_beads.sh index 8dfbfa8..1804c38 100755 --- a/mcptools/sync_beads.sh +++ b/mcptools/sync_beads.sh @@ -19,5 +19,5 @@ if [ -z "$MOUNT" ]; then exit 1 fi -echo "sync" | 9p write "anvillm/beads/$MOUNT/ctl" +echo "sync" | 9p write "beads/$MOUNT/ctl" echo "synced $MOUNT" diff --git a/mcptools/umount_beads.sh b/mcptools/umount_beads.sh index 546ab7b..2ec268c 100755 --- a/mcptools/umount_beads.sh +++ b/mcptools/umount_beads.sh @@ -19,5 +19,5 @@ if [ -z "$MOUNT" ]; then exit 1 fi -echo "umount $MOUNT" | 9p write anvillm/beads/ctl +echo "umount $MOUNT" | 9p write beads/ctl echo "unmounted $MOUNT" diff --git a/mcptools/unclaim_bead.sh b/mcptools/unclaim_bead.sh index fe2feba..01ef793 100755 --- a/mcptools/unclaim_bead.sh +++ b/mcptools/unclaim_bead.sh @@ -21,5 +21,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ]; then exit 1 fi -echo "unclaim $BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl +echo "unclaim $BEAD_ID" | 9p write beads/$MOUNT/ctl echo "unclaimed $BEAD_ID" diff --git a/mcptools/unlabel_bead.sh b/mcptools/unlabel_bead.sh index c6882d4..9f516ee 100755 --- a/mcptools/unlabel_bead.sh +++ b/mcptools/unlabel_bead.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD_ID" ] || [ -z "$LABEL" ]; then exit 1 fi -echo "unlabel $BEAD_ID $LABEL" | 9p write anvillm/beads/$MOUNT/ctl +echo "unlabel $BEAD_ID $LABEL" | 9p write beads/$MOUNT/ctl echo "unlabeled $BEAD_ID: $LABEL" diff --git a/mcptools/unrelate_beads.sh b/mcptools/unrelate_beads.sh index e7a092b..6663b6b 100755 --- a/mcptools/unrelate_beads.sh +++ b/mcptools/unrelate_beads.sh @@ -23,5 +23,5 @@ if [ -z "$MOUNT" ] || [ -z "$BEAD1" ] || [ -z "$BEAD2" ]; then exit 1 fi -echo "unrelate $BEAD1 $BEAD2" | 9p write anvillm/beads/$MOUNT/ctl +echo "unrelate $BEAD1 $BEAD2" | 9p write beads/$MOUNT/ctl echo "unrelated $BEAD1 ↔ $BEAD2" diff --git a/mcptools/update_bead.sh b/mcptools/update_bead.sh index 49165ee..eb95b96 100755 --- a/mcptools/update_bead.sh +++ b/mcptools/update_bead.sh @@ -27,19 +27,19 @@ fi if [ "$FIELD" = "parent" ]; then # Reparent: create new bead under parent with same data, then delete original - JSON=$(9p read "anvillm/beads/$MOUNT/$BEAD_ID/json") + JSON=$(9p read "beads/$MOUNT/$BEAD_ID/json") TITLE=$(echo "$JSON" | jq -r '.title') DESC=$(echo "$JSON" | jq -r '.description // ""') STATUS=$(echo "$JSON" | jq -r '.status') # Create under new parent - printf "new '%s' '%s' %s\n" "$TITLE" "$DESC" "$VALUE" | 9p write anvillm/beads/$MOUNT/ctl + printf "new '%s' '%s' %s\n" "$TITLE" "$DESC" "$VALUE" | 9p write beads/$MOUNT/ctl # Delete original - printf "delete %s\n" "$BEAD_ID" | 9p write anvillm/beads/$MOUNT/ctl + printf "delete %s\n" "$BEAD_ID" | 9p write beads/$MOUNT/ctl echo "reparented $BEAD_ID under $VALUE" else - printf "update %s %s '%s'\n" "$BEAD_ID" "$FIELD" "$VALUE" | 9p write anvillm/beads/$MOUNT/ctl + printf "update %s %s '%s'\n" "$BEAD_ID" "$FIELD" "$VALUE" | 9p write beads/$MOUNT/ctl echo "updated $BEAD_ID.$FIELD: $VALUE" fi diff --git a/mcptools/wait_for_bead.sh b/mcptools/wait_for_bead.sh index dc4ff6b..41ca997 100755 --- a/mcptools/wait_for_bead.sh +++ b/mcptools/wait_for_bead.sh @@ -19,7 +19,7 @@ if [ -z "$MOUNT" ]; then fi # Derive scope from cwd relative to mount's cwd -MOUNT_CWD=$(9p read "anvillm/beads/$MOUNT/cwd" 2>/dev/null) +MOUNT_CWD=$(9p read "beads/$MOUNT/cwd" 2>/dev/null) if [ -n "$MOUNT_CWD" ]; then REL_PATH="${PWD#"$MOUNT_CWD"}" REL_PATH="${REL_PATH#/}"