Extract beads to standalone 9beads service

- Remove embedded BeadsFS from anvilsrv
- Remove beads.go and related tests from p9 package
- Update Assist to connect to separate 'beads' 9P service
- Update mcptools scripts: anvillm/beads/* -> beads/*

Beads now served by 9beads (separate repo/process).
This commit is contained in:
Levi Neely 2026-03-20 15:13:02 +01:00
parent daf50d9783
commit 8e50b84707
42 changed files with 148 additions and 2735 deletions

View File

@ -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 <child> <parent> 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)))
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "[]"

View File

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

View File

@ -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 "[]"

View File

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

View File

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

View File

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

View File

@ -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 "[]"

View File

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

View File

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

View File

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

View File

@ -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 "[]"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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#/}"