diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 0c0c298e32..1f57cdd4dd 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -26,7 +26,7 @@ EXAMPLES: bd import backup.jsonl # Import from a specific file bd import --dry-run # Show what would be imported`, GroupID: "sync", - RunE: runImport, + RunE: runImport, } var ( diff --git a/cmd/bd/init_agent.go b/cmd/bd/init_agent.go index 92834bbef7..c7b6445bfe 100644 --- a/cmd/bd/init_agent.go +++ b/cmd/bd/init_agent.go @@ -25,6 +25,8 @@ func addAgentsInstructions(verbose bool, templatePath string) { } // updateAgentFile creates or updates an agent instructions file with embedded template content. +// When a beads section already exists (legacy or current), it is updated to the latest +// versioned format so that `bd init` never silently locks in stale sections. func updateAgentFile(filename string, verbose bool, templatePath string) error { // Check if file exists //nolint:gosec // G304: filename comes from hardcoded list in addAgentsInstructions @@ -43,6 +45,12 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error { newContent = agents.EmbeddedDefault() } + // Ensure the beads section uses versioned markers even in new files. + // EmbeddedDefault() may contain legacy markers; upgrade them. + if strings.Contains(newContent, "BEGIN BEADS INTEGRATION") && !strings.Contains(newContent, "profile:") { + newContent = upgradeBeadsSection(newContent, agents.ProfileFull) + } + // #nosec G306 - markdown needs to be readable if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil { return fmt.Errorf("failed to create %s: %w", filename, err) @@ -60,19 +68,29 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error { hasBeads := strings.Contains(contentStr, "BEGIN BEADS INTEGRATION") if hasBeads { - if verbose { - fmt.Printf(" %s already has agent instructions\n", filename) + // Update existing section to latest versioned format (upgrades legacy markers) + updated := upgradeBeadsSection(contentStr, agents.ProfileFull) + if updated != contentStr { + // #nosec G306 - markdown needs to be readable + if err := os.WriteFile(filename, []byte(updated), 0644); err != nil { + return fmt.Errorf("failed to update %s: %w", filename, err) + } + if verbose { + fmt.Printf(" %s Updated beads section in %s to latest format\n", ui.RenderPass("✓"), filename) + } + } else if verbose { + fmt.Printf(" %s already has current agent instructions\n", filename) } return nil } - // Append beads section (includes landing-the-plane) + // Append beads section with profile metadata (includes landing-the-plane) newContent := contentStr if !strings.HasSuffix(newContent, "\n") { newContent += "\n" } - newContent += "\n" + agents.EmbeddedBeadsSection() + newContent += "\n" + agents.RenderSection(agents.ProfileFull) // #nosec G306 - markdown needs to be readable if err := os.WriteFile(filename, []byte(newContent), 0644); err != nil { @@ -84,6 +102,37 @@ func updateAgentFile(filename string, verbose bool, templatePath string) error { return nil } +// upgradeBeadsSection replaces the beads section markers and content with the +// latest versioned format for the given profile. This handles both legacy +// markers (no metadata) and stale versioned markers (wrong hash). +func upgradeBeadsSection(content string, profile agents.Profile) string { + beginIdx := strings.Index(content, "" + endIdx := strings.Index(content, endMarker) + + if beginIdx == -1 || endIdx == -1 || beginIdx > endIdx { + return content + } + + // Check if already current + firstLine := content[beginIdx:] + if nl := strings.Index(firstLine, "\n"); nl != -1 { + firstLine = firstLine[:nl] + } + meta := agents.ParseMarker(firstLine) + if meta != nil && meta.Hash == agents.CurrentHash(profile) && meta.Profile == profile { + return content // already up to date + } + + // Replace section + endOfEndMarker := endIdx + len(endMarker) + if endOfEndMarker < len(content) && content[endOfEndMarker] == '\n' { + endOfEndMarker++ + } + + return content[:beginIdx] + agents.RenderSection(profile) + content[endOfEndMarker:] +} + // setupClaudeSettings creates or updates .claude/settings.local.json with onboard instruction func setupClaudeSettings(verbose bool) error { claudeDir := ".claude" diff --git a/cmd/bd/setup/agents.go b/cmd/bd/setup/agents.go index ecc05d9eed..97b4f33932 100644 --- a/cmd/bd/setup/agents.go +++ b/cmd/bd/setup/agents.go @@ -5,11 +5,16 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/steveyegge/beads/internal/templates/agents" + "github.com/steveyegge/beads/internal/utils" ) +// readFileBytesImpl is used in tests; avoids import cycle. +var readFileBytesImpl = os.ReadFile + // AGENTS.md integration markers for beads section const ( agentsBeginMarker = "" @@ -19,6 +24,7 @@ const ( var ( errAgentsFileMissing = errors.New("agents file not found") errBeadsSectionMissing = errors.New("beads section missing") + errBeadsSectionStale = errors.New("beads section is stale") ) const muxAgentInstructionsURL = "https://mux.coder.com/AGENTS.md" @@ -34,6 +40,7 @@ type agentsIntegration struct { setupCommand string readHint string docsURL string + profile agents.Profile // "full" or "minimal"; empty defaults to "full" } func defaultAgentsEnv() agentsEnv { @@ -44,13 +51,45 @@ func defaultAgentsEnv() agentsEnv { } } +// containsBeadsMarker returns true if content contains a BEGIN BEADS INTEGRATION marker +// (either legacy or new format with metadata). +func containsBeadsMarker(content string) bool { + return strings.Contains(content, "\nContent\n\n" + if !containsBeadsMarker(content) { + t.Error("should detect legacy marker") + } +} + +func TestContainsBeadsMarkerNew(t *testing.T) { + content := "# Header\n\n\nContent\n\n" + if !containsBeadsMarker(content) { + t.Error("should detect new-format marker") + } +} + +func TestContainsBeadsMarkerAbsent(t *testing.T) { + content := "# Header\n\nNo beads here\n" + if containsBeadsMarker(content) { + t.Error("should not detect marker when absent") + } +} + +func TestUpdateBeadsSectionLegacyToNew(t *testing.T) { + // Legacy marker should be replaced with new-format marker + legacy := `# Header + + +Old content here + + +# Footer` + + updated := updateBeadsSection(legacy) + + // Should have new-format marker with profile and hash + if !strings.Contains(updated, "profile:full") { + t.Error("updated section should use new-format marker with profile:full") + } + if !strings.Contains(updated, "hash:") { + t.Error("updated section should use new-format marker with hash") + } + + // Should preserve surrounding content + if !strings.Contains(updated, "# Header") { + t.Error("should preserve header") + } + if !strings.Contains(updated, "# Footer") { + t.Error("should preserve footer") + } + + // Old content should be replaced + if strings.Contains(updated, "Old content here") { + t.Error("old content should be replaced") + } +} + +func TestUpdateBeadsSectionNewFormatUpdate(t *testing.T) { + // New-format marker should also be replaceable + content := `# Header + + +Stale content + + +# Footer` + + updated := updateBeadsSection(content) + + if strings.Contains(updated, "oldoldhash") { + t.Error("old hash should be replaced") + } + if strings.Contains(updated, "Stale content") { + t.Error("stale content should be replaced") + } + if !strings.Contains(updated, "# Header") || !strings.Contains(updated, "# Footer") { + t.Error("surrounding content should be preserved") + } +} + +func TestRemoveBeadsSectionLegacy(t *testing.T) { + content := "Header\n\nContent\n\nFooter" + result := removeBeadsSection(content) + if strings.Contains(result, "BEGIN BEADS") { + t.Error("markers should be removed") + } + if !strings.Contains(result, "Header") || !strings.Contains(result, "Footer") { + t.Error("surrounding content should be preserved") + } +} + +func TestRemoveBeadsSectionNewFormat(t *testing.T) { + content := "Header\n\nContent\n\nFooter" + result := removeBeadsSection(content) + if strings.Contains(result, "BEGIN BEADS") { + t.Error("markers should be removed") + } + if !strings.Contains(result, "Header") || !strings.Contains(result, "Footer") { + t.Error("surrounding content should be preserved") + } +} + +func TestUpdateBeadsSectionWithProfile(t *testing.T) { + // Test with explicit profile parameter + content := `# Header + + +Old content + + +# Footer` + + updated := updateBeadsSectionWithProfile(content, agents.ProfileMinimal) + if !strings.Contains(updated, "profile:minimal") { + t.Error("should use minimal profile") + } + if !strings.Contains(updated, "# Header") || !strings.Contains(updated, "# Footer") { + t.Error("should preserve surrounding content") + } +} + +func TestInstallAgentsWithProfileCreatesNew(t *testing.T) { + env, _, _ := newFactoryTestEnv(t) + integration := agentsIntegration{ + name: "TestAgent", + setupCommand: "bd setup testagent", + profile: agents.ProfileMinimal, + } + if err := installAgents(env, integration); err != nil { + t.Fatalf("installAgents returned error: %v", err) + } + data, err := readFileBytes(env.agentsPath) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + content := string(data) + if !strings.Contains(content, "profile:minimal") { + t.Error("new file should use minimal profile from integration") + } +} + +func TestInstallAgentsDefaultsToFullProfile(t *testing.T) { + env, _, _ := newFactoryTestEnv(t) + integration := agentsIntegration{ + name: "TestAgent", + setupCommand: "bd setup testagent", + // no profile set — should default to full + } + if err := installAgents(env, integration); err != nil { + t.Fatalf("installAgents returned error: %v", err) + } + data, err := readFileBytes(env.agentsPath) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + content := string(data) + if !strings.Contains(content, "profile:full") { + t.Error("default profile should be full") + } +} + +// readFileBytes is a test helper to read file content +func readFileBytes(path string) ([]byte, error) { + return readFileBytesImpl(path) +} + +func TestExistingBeadsProfileLegacy(t *testing.T) { + content := "# Header\n\nContent\n\n" + got := existingBeadsProfile(content) + if got != agents.ProfileFull { + t.Errorf("legacy marker should return ProfileFull, got %q", got) + } +} + +func TestExistingBeadsProfileFull(t *testing.T) { + content := "\nContent\n\n" + got := existingBeadsProfile(content) + if got != agents.ProfileFull { + t.Errorf("expected ProfileFull, got %q", got) + } +} + +func TestExistingBeadsProfileMinimal(t *testing.T) { + content := "\nContent\n\n" + got := existingBeadsProfile(content) + if got != agents.ProfileMinimal { + t.Errorf("expected ProfileMinimal, got %q", got) + } +} + +func TestExistingBeadsProfileNoMarker(t *testing.T) { + content := "# Just a file\nNo markers\n" + got := existingBeadsProfile(content) + if got != agents.ProfileFull { + t.Errorf("no marker should default to ProfileFull, got %q", got) + } +} + +func TestCheckAgentsDetectsStale(t *testing.T) { + env, stdout, _ := newFactoryTestEnv(t) + // Write a section with a bogus hash so it's stale + content := "\nOld content\n\n" + if err := os.WriteFile(env.agentsPath, []byte(content), 0644); err != nil { + t.Fatalf("write: %v", err) + } + integration := agentsIntegration{ + name: "TestAgent", + setupCommand: "bd setup testagent", + profile: agents.ProfileFull, + } + err := checkAgents(env, integration) + if !errors.Is(err, errBeadsSectionStale) { + t.Fatalf("expected errBeadsSectionStale, got %v", err) + } + if !strings.Contains(stdout.String(), "stale") { + t.Error("expected stale message in stdout") + } +} + +func TestCheckAgentsCurrent(t *testing.T) { + env, stdout, _ := newFactoryTestEnv(t) + section := agents.RenderSection(agents.ProfileFull) + if err := os.WriteFile(env.agentsPath, []byte(section), 0644); err != nil { + t.Fatalf("write: %v", err) + } + integration := agentsIntegration{ + name: "TestAgent", + setupCommand: "bd setup testagent", + profile: agents.ProfileFull, + } + if err := checkAgents(env, integration); err != nil { + t.Fatalf("expected nil error for current section, got %v", err) + } + if !strings.Contains(stdout.String(), "current") { + t.Error("expected (current) in output") + } +} + +func TestCheckAgentsMinimalAcceptsFullProfile(t *testing.T) { + env, _, _ := newFactoryTestEnv(t) + section := agents.RenderSection(agents.ProfileFull) + if err := os.WriteFile(env.agentsPath, []byte(section), 0644); err != nil { + t.Fatalf("write: %v", err) + } + integration := agentsIntegration{ + name: "ClaudeCode", + setupCommand: "bd setup claude", + profile: agents.ProfileMinimal, + } + if err := checkAgents(env, integration); err != nil { + t.Fatalf("expected full profile to be accepted for minimal integration, got %v", err) + } +} + +func TestCheckAgentsMissingUsesTargetFileName(t *testing.T) { + stdout := &bytes.Buffer{} + env := agentsEnv{ + agentsPath: filepath.Join(t.TempDir(), "CLAUDE.md"), + stdout: stdout, + stderr: &bytes.Buffer{}, + } + integration := agentsIntegration{name: "ClaudeCode", setupCommand: "bd setup claude", profile: agents.ProfileMinimal} + err := checkAgents(env, integration) + if !errors.Is(err, errAgentsFileMissing) { + t.Fatalf("expected errAgentsFileMissing, got %v", err) + } + if !strings.Contains(stdout.String(), "CLAUDE.md not found") { + t.Fatalf("expected target filename in output, got: %s", stdout.String()) + } +} + +func TestInstallAgentsPreservesFullProfile(t *testing.T) { + // Simulate: file already has full profile, requesting minimal install + env, stdout, _ := newFactoryTestEnv(t) + fullSection := agents.RenderSection(agents.ProfileFull) + if err := os.WriteFile(env.agentsPath, []byte(fullSection), 0644); err != nil { + t.Fatalf("write: %v", err) + } + integration := agentsIntegration{ + name: "MinimalAgent", + setupCommand: "bd setup minimalagent", + profile: agents.ProfileMinimal, + } + if err := installAgents(env, integration); err != nil { + t.Fatalf("installAgents: %v", err) + } + data, err := readFileBytes(env.agentsPath) + if err != nil { + t.Fatalf("read: %v", err) + } + content := string(data) + // Should preserve full profile, not downgrade to minimal + if !strings.Contains(content, "profile:full") { + t.Error("should preserve full profile when minimal requested on file with full") + } + if !strings.Contains(stdout.String(), "preserving") { + t.Error("expected informational message about preserving full profile") + } +} + +func TestInstallAgentsSymlinkSafety(t *testing.T) { + dir := t.TempDir() + realFile := filepath.Join(dir, "AGENTS.md") + linkPath := filepath.Join(dir, "CLAUDE.md") + + // Write full profile content to the real file + fullSection := agents.RenderSection(agents.ProfileFull) + if err := os.WriteFile(realFile, []byte(fullSection), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + // Create symlink + if err := os.Symlink(realFile, linkPath); err != nil { + t.Fatalf("symlink: %v", err) + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + env := agentsEnv{ + agentsPath: linkPath, // install targets the symlink + stdout: stdout, + stderr: stderr, + } + integration := agentsIntegration{ + name: "ClaudeCode", + setupCommand: "bd setup claude", + profile: agents.ProfileMinimal, + } + if err := installAgents(env, integration); err != nil { + t.Fatalf("installAgents via symlink: %v", err) + } + + // Read the real file — should still have full profile + data, err := os.ReadFile(realFile) + if err != nil { + t.Fatalf("read real file: %v", err) + } + if !strings.Contains(string(data), "profile:full") { + t.Error("symlink target should preserve full profile") + } +} + +func TestLegacyToNewMigrationViaInstall(t *testing.T) { + env, _, _ := newFactoryTestEnv(t) + // Seed with legacy markers + legacy := "# Header\n\n\nOld content\n\n\n# Footer" + if err := os.WriteFile(env.agentsPath, []byte(legacy), 0644); err != nil { + t.Fatalf("write: %v", err) + } + integration := agentsIntegration{ + name: "Factory.ai", + setupCommand: "bd setup factory", + profile: agents.ProfileFull, + } + if err := installAgents(env, integration); err != nil { + t.Fatalf("installAgents: %v", err) + } + data, err := readFileBytes(env.agentsPath) + if err != nil { + t.Fatalf("read: %v", err) + } + content := string(data) + // Should now have versioned markers + if !strings.Contains(content, "profile:full") { + t.Error("legacy markers should be upgraded to versioned format") + } + if !strings.Contains(content, "hash:") { + t.Error("upgraded section should contain hash") + } + if strings.Contains(content, "Old content") { + t.Error("old legacy content should be replaced") + } + if !strings.Contains(content, "# Header") || !strings.Contains(content, "# Footer") { + t.Error("surrounding content should be preserved") + } +} diff --git a/cmd/bd/setup/claude.go b/cmd/bd/setup/claude.go index 166c494c4b..0d50dd22ef 100644 --- a/cmd/bd/setup/claude.go +++ b/cmd/bd/setup/claude.go @@ -7,6 +7,8 @@ import ( "io" "os" "path/filepath" + + "github.com/steveyegge/beads/internal/templates/agents" ) var ( @@ -14,6 +16,14 @@ var ( errClaudeHooksMissing = errors.New("claude hooks not installed") ) +const claudeInstructionsFile = "CLAUDE.md" + +var claudeAgentsIntegration = agentsIntegration{ + name: "Claude Code", + setupCommand: "bd setup claude", + profile: agents.ProfileMinimal, +} + type claudeEnv struct { stdout io.Writer stderr io.Writer @@ -54,6 +64,14 @@ func globalSettingsPath(home string) string { return filepath.Join(home, ".claude", "settings.json") } +func claudeAgentsEnv(env claudeEnv) agentsEnv { + return agentsEnv{ + agentsPath: filepath.Join(env.projectDir, claudeInstructionsFile), + stdout: env.stdout, + stderr: env.stderr, + } +} + // InstallClaude installs Claude Code hooks func InstallClaude(project bool, stealth bool) { env, err := claudeEnvProvider() @@ -127,6 +145,13 @@ func installClaude(env claudeEnv, project bool, stealth bool) error { return err } + // Install minimal beads section in CLAUDE.md. + // Hooks handle the heavy lifting via bd prime; CLAUDE.md just needs a pointer. + if err := installAgents(claudeAgentsEnv(env), claudeAgentsIntegration); err != nil { + // Non-fatal: hooks are already installed + _, _ = fmt.Fprintf(env.stderr, "Warning: failed to update %s: %v\n", claudeInstructionsFile, err) + } + _, _ = fmt.Fprintln(env.stdout, "\n✓ Claude Code integration installed") _, _ = fmt.Fprintf(env.stdout, " Settings: %s\n", settingsPath) _, _ = fmt.Fprintln(env.stdout, "\nRestart Claude Code for changes to take effect.") @@ -153,15 +178,15 @@ func checkClaude(env claudeEnv) error { switch { case hasBeadsHooks(globalSettings): _, _ = fmt.Fprintf(env.stdout, "✓ Global hooks installed: %s\n", globalSettings) - return nil case hasBeadsHooks(projectSettings): _, _ = fmt.Fprintf(env.stdout, "✓ Project hooks installed: %s\n", projectSettings) - return nil default: _, _ = fmt.Fprintln(env.stdout, "✗ No hooks installed") _, _ = fmt.Fprintln(env.stdout, " Run: bd setup claude") return errClaudeHooksMissing } + + return checkAgents(claudeAgentsEnv(env), claudeAgentsIntegration) } // RemoveClaude removes Claude Code hooks @@ -190,35 +215,38 @@ func removeClaude(env claudeEnv, project bool) error { data, err := env.readFile(settingsPath) if err != nil { _, _ = fmt.Fprintln(env.stdout, "No settings file found") - return nil - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err) - return err - } - - hooks, ok := settings["hooks"].(map[string]interface{}) - if !ok { - _, _ = fmt.Fprintln(env.stdout, "No hooks found") - return nil - } + } else { + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err) + return err + } - removeHookCommand(hooks, "SessionStart", "bd prime") - removeHookCommand(hooks, "PreCompact", "bd prime") - removeHookCommand(hooks, "SessionStart", "bd prime --stealth") - removeHookCommand(hooks, "PreCompact", "bd prime --stealth") + hooks, ok := settings["hooks"].(map[string]interface{}) + if !ok { + _, _ = fmt.Fprintln(env.stdout, "No hooks found") + } else { + removeHookCommand(hooks, "SessionStart", "bd prime") + removeHookCommand(hooks, "PreCompact", "bd prime") + removeHookCommand(hooks, "SessionStart", "bd prime --stealth") + removeHookCommand(hooks, "PreCompact", "bd prime --stealth") + + data, err = json.MarshalIndent(settings, "", " ") + if err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err) + return err + } - data, err = json.MarshalIndent(settings, "", " ") - if err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err) - return err + if err := env.writeFile(settingsPath, data); err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err) + return err + } + } } - if err := env.writeFile(settingsPath, data); err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err) - return err + if err := removeAgents(claudeAgentsEnv(env), claudeAgentsIntegration); err != nil { + // Non-fatal + _, _ = fmt.Fprintf(env.stderr, "Warning: failed to update %s: %v\n", claudeInstructionsFile, err) } _, _ = fmt.Fprintln(env.stdout, "✓ Claude hooks removed") diff --git a/cmd/bd/setup/claude_test.go b/cmd/bd/setup/claude_test.go index 4586af61ab..5f6d6300fc 100644 --- a/cmd/bd/setup/claude_test.go +++ b/cmd/bd/setup/claude_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/steveyegge/beads/internal/templates/agents" ) func newClaudeTestEnv(t *testing.T) (claudeEnv, *bytes.Buffer, *bytes.Buffer) { @@ -567,6 +569,14 @@ func TestInstallClaudeProject(t *testing.T) { if !hasBeadsHooks(projectSettingsPath(env.projectDir)) { t.Fatal("project hooks not detected") } + instructionsPath := filepath.Join(env.projectDir, claudeInstructionsFile) + instructions, err := os.ReadFile(instructionsPath) + if err != nil { + t.Fatalf("read %s: %v", claudeInstructionsFile, err) + } + if !strings.Contains(string(instructions), "profile:minimal") { + t.Fatalf("expected minimal profile in %s", claudeInstructionsFile) + } if !strings.Contains(stdout.String(), "project") { t.Error("expected project installation message") } @@ -587,6 +597,10 @@ func TestInstallClaudeGlobalStealth(t *testing.T) { if !strings.Contains(string(data), "bd prime --stealth") { t.Error("expected stealth command in settings") } + instructionsPath := filepath.Join(env.projectDir, claudeInstructionsFile) + if _, err := os.Stat(instructionsPath); err != nil { + t.Fatalf("expected %s to be created: %v", claudeInstructionsFile, err) + } if !strings.Contains(stdout.String(), "globally") { t.Error("expected global installation message") } @@ -634,6 +648,9 @@ func TestCheckClaudeScenarios(t *testing.T) { }, }, }) + if err := os.WriteFile(filepath.Join(env.projectDir, claudeInstructionsFile), []byte(agents.RenderSection(agents.ProfileMinimal)), 0o644); err != nil { + t.Fatalf("write %s: %v", claudeInstructionsFile, err) + } if err := checkClaude(env); err != nil { t.Fatalf("checkClaude: %v", err) } @@ -656,6 +673,9 @@ func TestCheckClaudeScenarios(t *testing.T) { }, }, }) + if err := os.WriteFile(filepath.Join(env.projectDir, claudeInstructionsFile), []byte(agents.RenderSection(agents.ProfileMinimal)), 0o644); err != nil { + t.Fatalf("write %s: %v", claudeInstructionsFile, err) + } if err := checkClaude(env); err != nil { t.Fatalf("checkClaude: %v", err) } @@ -673,6 +693,28 @@ func TestCheckClaudeScenarios(t *testing.T) { t.Error("expected guidance message") } }) + + t.Run("missing instructions", func(t *testing.T) { + env, stdout, _ := newClaudeTestEnv(t) + writeSettings(t, globalSettingsPath(env.homeDir), map[string]interface{}{ + "hooks": map[string]interface{}{ + "SessionStart": []interface{}{ + map[string]interface{}{ + "matcher": "", + "hooks": []interface{}{ + map[string]interface{}{"type": "command", "command": "bd prime"}, + }, + }, + }, + }, + }) + if err := checkClaude(env); !errors.Is(err, errAgentsFileMissing) { + t.Fatalf("expected errAgentsFileMissing, got %v", err) + } + if !strings.Contains(stdout.String(), claudeInstructionsFile+" not found") { + t.Fatalf("expected missing %s message, got: %s", claudeInstructionsFile, stdout.String()) + } + }) } func TestRemoveClaudeScenarios(t *testing.T) { @@ -692,6 +734,10 @@ func TestRemoveClaudeScenarios(t *testing.T) { }, }, }) + instructionsPath := filepath.Join(env.projectDir, claudeInstructionsFile) + if err := os.WriteFile(instructionsPath, []byte(agents.RenderSection(agents.ProfileMinimal)), 0o644); err != nil { + t.Fatalf("seed %s: %v", claudeInstructionsFile, err) + } if err := removeClaude(env, false); err != nil { t.Fatalf("removeClaude: %v", err) } @@ -702,6 +748,13 @@ func TestRemoveClaudeScenarios(t *testing.T) { if strings.Contains(string(data), "bd prime") { t.Error("expected bd prime hooks removed") } + instructions, err := os.ReadFile(instructionsPath) + if err != nil { + t.Fatalf("read %s: %v", claudeInstructionsFile, err) + } + if strings.Contains(string(instructions), "BEGIN BEADS INTEGRATION") { + t.Fatalf("expected beads section removed from %s", claudeInstructionsFile) + } if !strings.Contains(stdout.String(), "hooks removed") { t.Error("expected success message") } diff --git a/cmd/bd/setup/codex.go b/cmd/bd/setup/codex.go index 8d06c512f6..843a73462a 100644 --- a/cmd/bd/setup/codex.go +++ b/cmd/bd/setup/codex.go @@ -1,9 +1,12 @@ package setup +import "github.com/steveyegge/beads/internal/templates/agents" + var codexIntegration = agentsIntegration{ name: "Codex CLI", setupCommand: "bd setup codex", readHint: "Codex reads AGENTS.md at the start of each run or session. Restart Codex if it is already running.", + profile: agents.ProfileFull, } var codexEnvProvider = defaultAgentsEnv diff --git a/cmd/bd/setup/factory.go b/cmd/bd/setup/factory.go index d45a41a13b..c84e2c75e4 100644 --- a/cmd/bd/setup/factory.go +++ b/cmd/bd/setup/factory.go @@ -1,9 +1,12 @@ package setup +import "github.com/steveyegge/beads/internal/templates/agents" + var factoryIntegration = agentsIntegration{ name: "Factory.ai (Droid)", setupCommand: "bd setup factory", readHint: "Factory Droid will automatically read AGENTS.md on session start.", + profile: agents.ProfileFull, } type factoryEnv = agentsEnv diff --git a/cmd/bd/setup/factory_test.go b/cmd/bd/setup/factory_test.go index f11f9589f2..59340e0c6a 100644 --- a/cmd/bd/setup/factory_test.go +++ b/cmd/bd/setup/factory_test.go @@ -12,7 +12,7 @@ import ( ) func TestUpdateBeadsSection(t *testing.T) { - beadsSection := agents.EmbeddedBeadsSection() + beadsSection := agents.RenderSection(agents.ProfileFull) tests := []struct { name string @@ -135,7 +135,7 @@ func TestCreateNewAgentsFile(t *testing.T) { t.Error("Missing header in new agents file") } - if !strings.Contains(content, agentsBeginMarker) { + if !containsBeadsMarker(content) { t.Error("Missing begin marker in new agents file") } @@ -143,6 +143,10 @@ func TestCreateNewAgentsFile(t *testing.T) { t.Error("Missing end marker in new agents file") } + if !strings.Contains(content, "profile:full") { + t.Error("Missing profile metadata in new agents file") + } + if !strings.Contains(content, "## Build & Test") { t.Error("Missing Build & Test section") } @@ -183,7 +187,7 @@ func TestInstallFactoryCreatesNewFile(t *testing.T) { t.Fatalf("failed to read AGENTS.md: %v", err) } content := string(data) - if !strings.Contains(content, agentsBeginMarker) || !strings.Contains(content, agentsEndMarker) { + if !containsBeadsMarker(content) || !strings.Contains(content, agentsEndMarker) { t.Fatal("missing factory markers in new file") } if !strings.Contains(stdout.String(), "Factory.ai (Droid) integration installed") { @@ -260,7 +264,8 @@ func TestCheckFactoryScenarios(t *testing.T) { t.Run("success", func(t *testing.T) { env, stdout, _ := newFactoryTestEnv(t) - beadsSection := agents.EmbeddedBeadsSection() + // Use current rendered section (not legacy EmbeddedBeadsSection) so check reports "current" + beadsSection := agents.RenderSection(agents.ProfileFull) if err := os.WriteFile(env.agentsPath, []byte(beadsSection), 0644); err != nil { t.Fatalf("failed to seed file: %v", err) } @@ -271,6 +276,21 @@ func TestCheckFactoryScenarios(t *testing.T) { t.Error("expected success output") } }) + + t.Run("stale legacy section", func(t *testing.T) { + env, stdout, _ := newFactoryTestEnv(t) + beadsSection := agents.EmbeddedBeadsSection() + if err := os.WriteFile(env.agentsPath, []byte(beadsSection), 0644); err != nil { + t.Fatalf("failed to seed file: %v", err) + } + err := checkFactory(env) + if !errors.Is(err, errBeadsSectionStale) { + t.Fatalf("expected errBeadsSectionStale, got %v", err) + } + if !strings.Contains(stdout.String(), "stale") { + t.Error("expected stale output") + } + }) } func TestRemoveFactoryScenarios(t *testing.T) { diff --git a/cmd/bd/setup/gemini.go b/cmd/bd/setup/gemini.go index a9123029f1..af36100ab9 100644 --- a/cmd/bd/setup/gemini.go +++ b/cmd/bd/setup/gemini.go @@ -7,6 +7,8 @@ import ( "io" "os" "path/filepath" + + "github.com/steveyegge/beads/internal/templates/agents" ) var ( @@ -14,6 +16,14 @@ var ( errGeminiHooksMissing = errors.New("gemini hooks not installed") ) +const geminiInstructionsFile = "GEMINI.md" + +var geminiAgentsIntegration = agentsIntegration{ + name: "Gemini CLI", + setupCommand: "bd setup gemini", + profile: agents.ProfileMinimal, +} + type geminiEnv struct { stdout io.Writer stderr io.Writer @@ -54,6 +64,14 @@ func geminiGlobalSettingsPath(home string) string { return filepath.Join(home, ".gemini", "settings.json") } +func geminiAgentsEnv(env geminiEnv) agentsEnv { + return agentsEnv{ + agentsPath: filepath.Join(env.projectDir, geminiInstructionsFile), + stdout: env.stdout, + stderr: env.stderr, + } +} + // InstallGemini installs Gemini CLI hooks func InstallGemini(project bool, stealth bool) { env, err := geminiEnvProvider() @@ -120,6 +138,13 @@ func installGemini(env geminiEnv, project bool, stealth bool) error { return err } + // Install minimal beads section in GEMINI.md. + // Hooks handle the heavy lifting via bd prime; GEMINI.md just needs a pointer. + if err := installAgents(geminiAgentsEnv(env), geminiAgentsIntegration); err != nil { + // Non-fatal: hooks are already installed + _, _ = fmt.Fprintf(env.stderr, "Warning: failed to update %s: %v\n", geminiInstructionsFile, err) + } + _, _ = fmt.Fprintln(env.stdout, "\n✓ Gemini CLI integration installed") _, _ = fmt.Fprintf(env.stdout, " Settings: %s\n", settingsPath) _, _ = fmt.Fprintln(env.stdout, "\nRestart Gemini CLI for changes to take effect.") @@ -146,15 +171,15 @@ func checkGemini(env geminiEnv) error { switch { case hasGeminiBeadsHooks(globalSettings): _, _ = fmt.Fprintf(env.stdout, "✓ Global hooks installed: %s\n", globalSettings) - return nil case hasGeminiBeadsHooks(projectSettings): _, _ = fmt.Fprintf(env.stdout, "✓ Project hooks installed: %s\n", projectSettings) - return nil default: _, _ = fmt.Fprintln(env.stdout, "✗ No hooks installed") _, _ = fmt.Fprintln(env.stdout, " Run: bd setup gemini") return errGeminiHooksMissing } + + return checkAgents(geminiAgentsEnv(env), geminiAgentsIntegration) } // RemoveGemini removes Gemini CLI hooks @@ -183,36 +208,39 @@ func removeGemini(env geminiEnv, project bool) error { data, err := env.readFile(settingsPath) if err != nil { _, _ = fmt.Fprintln(env.stdout, "No settings file found") - return nil - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err) - return err - } - - hooks, ok := settings["hooks"].(map[string]interface{}) - if !ok { - _, _ = fmt.Fprintln(env.stdout, "No hooks found") - return nil - } + } else { + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: failed to parse settings.json: %v\n", err) + return err + } - // Remove both variants from both events - removeHookCommand(hooks, "SessionStart", "bd prime") - removeHookCommand(hooks, "PreCompress", "bd prime") - removeHookCommand(hooks, "SessionStart", "bd prime --stealth") - removeHookCommand(hooks, "PreCompress", "bd prime --stealth") + hooks, ok := settings["hooks"].(map[string]interface{}) + if !ok { + _, _ = fmt.Fprintln(env.stdout, "No hooks found") + } else { + // Remove both variants from both events + removeHookCommand(hooks, "SessionStart", "bd prime") + removeHookCommand(hooks, "PreCompress", "bd prime") + removeHookCommand(hooks, "SessionStart", "bd prime --stealth") + removeHookCommand(hooks, "PreCompress", "bd prime --stealth") + + data, err = json.MarshalIndent(settings, "", " ") + if err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err) + return err + } - data, err = json.MarshalIndent(settings, "", " ") - if err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: marshal settings: %v\n", err) - return err + if err := env.writeFile(settingsPath, data); err != nil { + _, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err) + return err + } + } } - if err := env.writeFile(settingsPath, data); err != nil { - _, _ = fmt.Fprintf(env.stderr, "Error: write settings: %v\n", err) - return err + if err := removeAgents(geminiAgentsEnv(env), geminiAgentsIntegration); err != nil { + // Non-fatal + _, _ = fmt.Fprintf(env.stderr, "Warning: failed to update %s: %v\n", geminiInstructionsFile, err) } _, _ = fmt.Fprintln(env.stdout, "✓ Gemini CLI hooks removed") diff --git a/cmd/bd/setup/gemini_test.go b/cmd/bd/setup/gemini_test.go index dd675d30b6..83f5948b9b 100644 --- a/cmd/bd/setup/gemini_test.go +++ b/cmd/bd/setup/gemini_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/steveyegge/beads/internal/templates/agents" ) func newGeminiTestEnv(t *testing.T) (geminiEnv, *bytes.Buffer, *bytes.Buffer) { @@ -113,6 +115,14 @@ func TestInstallGemini_Global(t *testing.T) { if !strings.Contains(out, "Gemini CLI integration installed") { t.Errorf("expected success message, got: %s", out) } + instructionsPath := filepath.Join(env.projectDir, geminiInstructionsFile) + instructions, err := os.ReadFile(instructionsPath) + if err != nil { + t.Fatalf("read %s: %v", geminiInstructionsFile, err) + } + if !strings.Contains(string(instructions), "profile:minimal") { + t.Fatalf("expected minimal profile in %s", geminiInstructionsFile) + } } func TestInstallGemini_Project(t *testing.T) { @@ -133,6 +143,10 @@ func TestInstallGemini_Project(t *testing.T) { if !strings.Contains(out, "Installing Gemini CLI hooks for this project") { t.Errorf("expected project install message, got: %s", out) } + instructionsPath := filepath.Join(env.projectDir, geminiInstructionsFile) + if _, err := os.Stat(instructionsPath); err != nil { + t.Fatalf("expected %s to be created: %v", geminiInstructionsFile, err) + } } func TestInstallGemini_Stealth(t *testing.T) { @@ -268,6 +282,29 @@ func TestCheckGemini_ProjectInstalled(t *testing.T) { } } +func TestCheckGemini_MissingInstructions(t *testing.T) { + env, stdout, _ := newGeminiTestEnv(t) + + if err := installGemini(env, false, false); err != nil { + t.Fatalf("installGemini: %v", err) + } + + if err := os.Remove(filepath.Join(env.projectDir, geminiInstructionsFile)); err != nil { + t.Fatalf("remove %s: %v", geminiInstructionsFile, err) + } + + stdout.Reset() + err := checkGemini(env) + if err != errAgentsFileMissing { + t.Fatalf("expected errAgentsFileMissing, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, geminiInstructionsFile+" not found") { + t.Fatalf("expected missing %s message, got: %s", geminiInstructionsFile, out) + } +} + func TestRemoveGemini_Global(t *testing.T) { env, stdout, _ := newGeminiTestEnv(t) @@ -275,6 +312,10 @@ func TestRemoveGemini_Global(t *testing.T) { if err := installGemini(env, false, false); err != nil { t.Fatalf("installGemini: %v", err) } + instructionsPath := filepath.Join(env.projectDir, geminiInstructionsFile) + if err := os.WriteFile(instructionsPath, []byte(agents.RenderSection(agents.ProfileMinimal)), 0o644); err != nil { + t.Fatalf("seed %s: %v", geminiInstructionsFile, err) + } stdout.Reset() @@ -292,6 +333,13 @@ func TestRemoveGemini_Global(t *testing.T) { if ok && len(sessionStart) > 0 { t.Error("SessionStart hooks should be empty") } + instructions, err := os.ReadFile(instructionsPath) + if err != nil { + t.Fatalf("read %s: %v", geminiInstructionsFile, err) + } + if strings.Contains(string(instructions), "BEGIN BEADS INTEGRATION") { + t.Fatalf("expected beads section removed from %s", geminiInstructionsFile) + } out := stdout.String() if !strings.Contains(out, "Gemini CLI hooks removed") { diff --git a/cmd/bd/setup/mux.go b/cmd/bd/setup/mux.go index 77fe7ef670..c2718bd68e 100644 --- a/cmd/bd/setup/mux.go +++ b/cmd/bd/setup/mux.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/steveyegge/beads/internal/templates/agents" ) const ( @@ -54,6 +56,7 @@ var ( setupCommand: "bd setup mux", readHint: "Mux reads AGENTS.md in workspace and global contexts. Restart the workspace session if it is already running.", docsURL: muxAgentInstructionsURL, + profile: agents.ProfileFull, } muxProjectIntegration = agentsIntegration{ @@ -61,6 +64,7 @@ var ( setupCommand: "bd setup mux --project", readHint: "Mux also supports layered workspace instructions via .mux/AGENTS.md.", docsURL: muxAgentInstructionsURL, + profile: agents.ProfileFull, } muxGlobalIntegration = agentsIntegration{ @@ -68,6 +72,7 @@ var ( setupCommand: "bd setup mux --global", readHint: "Mux global defaults can be stored in ~/.mux/AGENTS.md.", docsURL: muxAgentInstructionsURL, + profile: agents.ProfileFull, } muxEnvProvider = defaultAgentsEnv diff --git a/cmd/bd/setup/opencode.go b/cmd/bd/setup/opencode.go index f91ac05a28..324861e287 100644 --- a/cmd/bd/setup/opencode.go +++ b/cmd/bd/setup/opencode.go @@ -1,9 +1,12 @@ package setup +import "github.com/steveyegge/beads/internal/templates/agents" + var opencodeIntegration = agentsIntegration{ name: "OpenCode", setupCommand: "bd setup opencode", readHint: "OpenCode reads AGENTS.md at the start of each session. Restart OpenCode if it is already running.", + profile: agents.ProfileFull, } var opencodeEnvProvider = defaultAgentsEnv diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index a7d1f44e98..9f55d21aae 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -862,7 +862,8 @@ bd dolt push # Force immediate sync, bypass debounce bd setup factory # Factory.ai Droid - creates/updates AGENTS.md (universal standard) bd setup codex # Codex CLI - creates/updates AGENTS.md bd setup mux # Mux - creates/updates AGENTS.md -bd setup claude # Claude Code - installs SessionStart/PreCompact hooks +bd setup claude # Claude Code - installs hooks + manages CLAUDE.md (minimal profile) +bd setup gemini # Gemini CLI - installs hooks + manages GEMINI.md (minimal profile) bd setup cursor # Cursor IDE - creates .cursor/rules/beads.mdc bd setup aider # Aider - creates .aider.conf.yml @@ -871,6 +872,7 @@ bd setup factory --check bd setup codex --check bd setup mux --check bd setup claude --check +bd setup gemini --check bd setup cursor --check bd setup aider --check @@ -879,6 +881,7 @@ bd setup factory --remove bd setup codex --remove bd setup mux --remove bd setup claude --remove +bd setup gemini --remove bd setup cursor --remove bd setup aider --remove ``` @@ -888,18 +891,24 @@ bd setup aider --remove bd setup claude # Install globally (~/.claude/settings.json) bd setup claude --project # Install for this project only bd setup claude --stealth # Use stealth mode (flush only, no git operations) +bd setup gemini # Install globally (~/.gemini/settings.json) +bd setup gemini --project # Install for this project only +bd setup gemini --stealth # Use stealth mode (flush only, no git operations) bd setup mux --project # Also install .mux/AGENTS.md workspace layer bd setup mux --global # Also install ~/.mux/AGENTS.md global layer ``` **What each setup does:** -- **Factory.ai** (`bd setup factory`): Creates or updates AGENTS.md with beads workflow instructions (works with multiple AI tools using the AGENTS.md standard) -- **Codex CLI** (`bd setup codex`): Creates or updates AGENTS.md with beads workflow instructions for Codex -- **Mux** (`bd setup mux`): Creates or updates AGENTS.md with beads workflow instructions for Mux workspaces -- **Claude Code** (`bd setup claude`): Adds hooks to Claude Code's settings.json that run `bd prime` on SessionStart and PreCompact events +- **Factory.ai** (`bd setup factory`): Creates or updates AGENTS.md with beads workflow instructions (full profile — works with multiple AI tools using the AGENTS.md standard) +- **Codex CLI** (`bd setup codex`): Creates or updates AGENTS.md with beads workflow instructions for Codex (full profile) +- **Mux** (`bd setup mux`): Creates or updates AGENTS.md with beads workflow instructions for Mux workspaces (full profile) +- **Claude Code** (`bd setup claude`): Adds hooks to Claude Code's settings.json that run `bd prime` on SessionStart and PreCompact events and manages a minimal-profile beads section in `CLAUDE.md` +- **Gemini CLI** (`bd setup gemini`): Adds hooks to Gemini's settings.json that run `bd prime` on SessionStart and PreCompress events and manages a minimal-profile beads section in `GEMINI.md` - **Cursor** (`bd setup cursor`): Creates `.cursor/rules/beads.mdc` with workflow instructions - **Aider** (`bd setup aider`): Creates `.aider.conf.yml` with bd workflow instructions +**`--check` behavior:** For section-based integrations (including Claude/Gemini instruction files), reports status as `current` (up to date), `stale` (legacy or hash mismatch — run setup to update), or `missing` (no beads section). Stale and missing return non-zero exit codes. + See also: - [INSTALLING.md](INSTALLING.md#ide-and-editor-integrations) - Installation guide - [AIDER_INTEGRATION.md](AIDER_INTEGRATION.md) - Detailed Aider guide diff --git a/docs/DOLT-BACKEND.md b/docs/DOLT-BACKEND.md index 120acc2a0d..97f7502ecc 100644 --- a/docs/DOLT-BACKEND.md +++ b/docs/DOLT-BACKEND.md @@ -74,7 +74,7 @@ dolt: |---------------------|---------|-------------| | `BEADS_DOLT_SERVER_MODE` | `1` | Enable/disable server mode (`1`/`0`) | | `BEADS_DOLT_SERVER_HOST` | `127.0.0.1` | Server bind address | -| `BEADS_DOLT_SERVER_PORT` | `3306` | Server port (MySQL protocol) | +| `BEADS_DOLT_SERVER_PORT` | `3307` | Server port (MySQL protocol) | | `BEADS_DOLT_SERVER_USER` | `root` | MySQL username | | `BEADS_DOLT_SERVER_PASS` | (empty) | MySQL password | | `BEADS_DOLT_SHARED_SERVER` | (empty) | Shared server mode: `1` or `true` to enable | @@ -112,6 +112,131 @@ Shared server state lives in `~/.beads/shared-server/` and uses port 3308 by def (avoiding conflict with Gas Town on 3307). Each project's data remains isolated in its own database (named by project prefix). See [DOLT.md](DOLT.md) for details. +## Central Dolt Server (macOS) + +If you plan to use Gas Town or manage multiple beads projects from a single +machine, you can run a central persistent Dolt server instead of per-project +embedded instances. + +### Embedded vs Central Server + +| | Embedded (default) | Central Server | +|---|---|---| +| **Setup** | Zero-config — `bd init` handles everything | One-time server setup required | +| **Data location** | `.beads/dolt/` per project | Central directory (e.g. `/opt/homebrew/var/dolt`) | +| **Concurrency** | Single writer per project | Multi-writer via MySQL protocol | +| **Use case** | Solo development, single project | Gas Town, multiple projects, multiple agents | + +For single-project solo use, **embedded mode is recommended** — it requires no +setup. Switch to a central server when you need Gas Town or concurrent access. + +### Why Not `brew services start dolt`? + +After installing Dolt with `brew install dolt`, the natural next step is +`brew services start dolt`. However, this **silently ignores your config file**. + +The Homebrew formula runs `dolt sql-server` without the `--config` flag. Dolt +does **not** auto-discover `config.yaml` from its working directory — the config +file must be passed explicitly via `--config `. Any edits to +`/opt/homebrew/var/dolt/config.yaml` (port, host, etc.) have no effect when +started through `brew services`. + +### Setup with a Custom LaunchAgent + +Instead of `brew services`, create a custom LaunchAgent that passes the config +file explicitly. + +**1. Install Dolt and initialize its data directory:** + +```bash +brew install dolt + +# Initialize the dolt data directory (if not already done) +cd /opt/homebrew/var/dolt && dolt init +``` + +**2. Configure Dolt for port 3307:** + +```yaml +# /opt/homebrew/var/dolt/config.yaml +log_level: info + +listener: + host: 127.0.0.1 + port: 3307 + max_connections: 100 + +behavior: + autocommit: true +``` + +**3. Create the LaunchAgent plist:** + +```bash +cat > ~/Library/LaunchAgents/com.local.dolt-server.plist << 'EOF' + + + + + Label + com.local.dolt-server + ProgramArguments + + /opt/homebrew/opt/dolt/bin/dolt + sql-server + --config + config.yaml + + WorkingDirectory + /opt/homebrew/var/dolt + RunAtLoad + + KeepAlive + + StandardOutPath + /opt/homebrew/var/log/dolt.log + StandardErrorPath + /opt/homebrew/var/log/dolt.error.log + + +EOF +``` + +**4. Load the service:** + +```bash +launchctl load ~/Library/LaunchAgents/com.local.dolt-server.plist + +# Verify it's running +mysql -h 127.0.0.1 -P 3307 -u root -e "SELECT 1" +``` + +**5. Point beads at the central server** — add to `~/.zshrc` (or `~/.bashrc`): + +```bash +export BEADS_DOLT_SERVER_HOST="127.0.0.1" +export BEADS_DOLT_SERVER_PORT="3307" +export BEADS_DOLT_SERVER_MODE=1 +``` + +Now `bd init` in any project will connect to the central server instead of +spawning an embedded instance. + +### Managing the Service + +```bash +# Stop +launchctl unload ~/Library/LaunchAgents/com.local.dolt-server.plist + +# Restart (unload + load) +launchctl unload ~/Library/LaunchAgents/com.local.dolt-server.plist +launchctl load ~/Library/LaunchAgents/com.local.dolt-server.plist + +# Check logs +tail -f /opt/homebrew/var/log/dolt.log +``` + ## Sync Modes Dolt supports multiple sync strategies: diff --git a/docs/SETUP.md b/docs/SETUP.md index c320095d53..daab33787e 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -7,6 +7,23 @@ The `bd setup` command uses a **recipe-based architecture** to configure beads integration with AI coding tools. Recipes define where workflow instructions are written—built-in recipes handle popular tools, and you can add custom recipes for any tool. +### `bd prime` as SSOT + +`bd prime` is the **single source of truth** for operational workflow commands. The beads section in each tool's instruction file provides a pointer to `bd prime` for hook-enabled agents (Claude, Gemini) or the full command reference for hookless agents (Factory, Codex, Mux). + +### Profiles + +Each integration uses one of two **profiles** that control how much content is written to tool instruction files (`AGENTS.md`, `CLAUDE.md`, or `GEMINI.md`): + +| Profile | Used By | Content | +|---------|---------|---------| +| `full` | Factory, Codex, Mux, OpenCode | Complete command reference, issue types, priorities, workflow | +| `minimal` | Claude Code, Gemini CLI | Pointer to `bd prime`, quick reference only (~60% smaller) | + +Hook-enabled agents (Claude, Gemini) use the `minimal` profile because `bd prime` injects full context at session start. Hookless agents need the `full` profile because their instruction file is their only source of instructions. + +**Profile precedence:** If a file already has a `full` profile section and a `minimal` profile tool installs to the same file (e.g., via symlinks), the `full` profile is preserved to avoid information loss. + ### Built-in Recipes | Recipe | Path | Integration Type | @@ -15,8 +32,8 @@ The `bd setup` command uses a **recipe-based architecture** to configure beads i | `windsurf` | `.windsurf/rules/beads.md` | Rules file | | `cody` | `.cody/rules/beads.md` | Rules file | | `kilocode` | `.kilocode/rules/beads.md` | Rules file | -| `claude` | `~/.claude/settings.json` | SessionStart/PreCompact hooks | -| `gemini` | `~/.gemini/settings.json` | SessionStart/PreCompress hooks | +| `claude` | `~/.claude/settings.json` + `CLAUDE.md` | SessionStart/PreCompact hooks + minimal section | +| `gemini` | `~/.gemini/settings.json` + `GEMINI.md` | SessionStart/PreCompress hooks + minimal section | | `factory` | `AGENTS.md` | Marked section | | `codex` | `AGENTS.md` | Marked section | | `mux` | `AGENTS.md` | Marked section | @@ -74,7 +91,7 @@ Creates or updates `AGENTS.md` in your project root with: - Auto-sync explanation - Important rules for AI agents -The beads section is wrapped in HTML comments (``) for safe updates. +The beads section is wrapped in HTML comments (``) with metadata for safe updates. The begin marker includes profile and hash metadata (e.g., ``) for freshness detection. Legacy markers without metadata are auto-upgraded on the next install or update. ### AGENTS.md Standard @@ -94,7 +111,7 @@ Using AGENTS.md means one configuration file works across your entire AI tool ec | Flag | Description | |------|-------------| -| `--check` | Check if beads section exists in AGENTS.md | +| `--check` | Check if beads section exists and is current (reports `missing`, `stale`, or `current`) | | `--remove` | Remove beads section from AGENTS.md | ### Examples @@ -217,21 +234,26 @@ bd setup claude --stealth **Project installation** (`.claude/settings.local.json`): - Same hooks, but only active for this project +**Instruction file** (`CLAUDE.md` in project root): +- Minimal-profile beads section pointing to `bd prime` +- Managed with hash/version markers for safe updates and `--check` freshness detection + ### Flags | Flag | Description | |------|-------------| -| `--check` | Check if integration is installed | -| `--remove` | Remove beads hooks | +| `--check` | Check both hooks and the managed `CLAUDE.md` beads section | +| `--remove` | Remove beads hooks and managed `CLAUDE.md` beads section | | `--project` | Install for this project only (not globally) | | `--stealth` | Use `bd prime --stealth` (flush only, no git operations) | ### Examples ```bash -# Check if hooks are installed +# Check hooks + CLAUDE.md beads section bd setup claude --check # Output: ✓ Global hooks installed: /Users/you/.claude/settings.json +# ✓ Claude Code integration installed: /path/to/CLAUDE.md (current) # Remove hooks bd setup claude --remove @@ -275,21 +297,26 @@ bd setup gemini --stealth **Project installation** (`.gemini/settings.json`): - Same hooks, but only active for this project +**Instruction file** (`GEMINI.md` in project root): +- Minimal-profile beads section pointing to `bd prime` +- Managed with hash/version markers for safe updates and `--check` freshness detection + ### Flags | Flag | Description | |------|-------------| -| `--check` | Check if integration is installed | -| `--remove` | Remove beads hooks | +| `--check` | Check both hooks and the managed `GEMINI.md` beads section | +| `--remove` | Remove beads hooks and managed `GEMINI.md` beads section | | `--project` | Install for this project only (not globally) | | `--stealth` | Use `bd prime --stealth` (flush only, no git operations) | ### Examples ```bash -# Check if hooks are installed +# Check hooks + GEMINI.md beads section bd setup gemini --check # Output: ✓ Global hooks installed: /Users/you/.gemini/settings.json +# ✓ Gemini CLI integration installed: /path/to/GEMINI.md (current) # Remove hooks bd setup gemini --remove @@ -410,7 +437,7 @@ This respects Aider's philosophy of keeping humans in control while still levera | Feature | Factory.ai | Codex | Mux | Claude Code | Gemini CLI | Cursor | Aider | |---------|-----------|-------|-----|-------------|------------|--------|-------| | Command execution | Automatic | Automatic | Automatic | Automatic | Automatic | Automatic | Manual (/run) | -| Context injection | AGENTS.md | AGENTS.md | AGENTS.md | Hooks | Hooks | Rules file | Config file | +| Context injection | AGENTS.md | AGENTS.md | AGENTS.md | Hooks + CLAUDE.md | Hooks + GEMINI.md | Rules file | Config file | | Global install | No (per-project) | No (per-project) | No (per-project) | Yes | Yes | No (per-project) | No (per-project) | | Stealth mode | N/A | N/A | N/A | Yes | Yes | N/A | N/A | | Standard format | Yes (AGENTS.md) | Yes (AGENTS.md) | Yes (AGENTS.md) | No (proprietary) | No (proprietary) | No (proprietary) | No (proprietary) | @@ -429,7 +456,7 @@ This respects Aider's philosophy of keeping humans in control while still levera 4. **Use stealth mode in CI/CD** - `bd setup claude --stealth` or `bd setup gemini --stealth` avoids git operations that might fail in automated environments -5. **Commit AGENTS.md to git** - This ensures all team members and AI tools get the same instructions +5. **Commit instruction files to git** - This ensures all team members and AI tools get the same instructions (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, as applicable) 6. **Run `bd doctor` after setup** - Verifies the integration is working: ```bash diff --git a/internal/recipes/template.go b/internal/recipes/template.go index 39389f9727..4d8259abdc 100644 --- a/internal/recipes/template.go +++ b/internal/recipes/template.go @@ -11,20 +11,20 @@ This project uses [Beads (bd)](https://github.com/steveyegge/beads) for issue tr - Track ALL work in bd (never use markdown TODOs or comment-based task lists) - Use ` + "`bd ready`" + ` to find available work - Use ` + "`bd create`" + ` to track new issues/tasks/bugs -- Use ` + "`bd sync`" + ` at end of session to sync with git remote -- Git hooks auto-sync on commit/merge +- Use ` + "`bd dolt push`" + ` at end of session to sync with remote +- Run ` + "`bd prime`" + ` for complete workflow context (SSOT for operational commands) ## Quick Reference ` + "```bash" + ` -bd prime # Load complete workflow context +bd prime # Load complete workflow context (SSOT) bd ready # Show issues ready to work (no blockers) bd list --status=open # List all open issues -bd create --title="..." --type=task # Create new issue +bd create "title" -t task -p 2 # Create new issue bd update --claim # Claim work atomically bd close # Mark complete bd dep add # Add dependency -bd sync # Sync with git remote +bd dolt push # Sync with remote ` + "```" + ` ## Workflow @@ -33,7 +33,7 @@ bd sync # Sync with git remote 2. Claim an issue atomically: ` + "`bd update --claim`" + ` 3. Do the work 4. Mark complete: ` + "`bd close `" + ` -5. Sync: ` + "`bd sync`" + ` (or let git hooks handle it) +5. Push changes: ` + "`bd dolt push`" + ` ## Issue Types @@ -54,6 +54,7 @@ bd sync # Sync with git remote ## Context Loading Run ` + "`bd prime`" + ` to get complete workflow documentation in AI-optimized format. +` + "`bd prime`" + ` is the single source of truth for operational commands and session workflow. For detailed docs: see AGENTS.md, QUICKSTART.md, or run ` + "`bd --help`" + ` ` diff --git a/internal/storage/dolt/store.go b/internal/storage/dolt/store.go index 02417fe8b4..3e9f22d4af 100644 --- a/internal/storage/dolt/store.go +++ b/internal/storage/dolt/store.go @@ -1844,7 +1844,6 @@ func (s *DoltStore) tryAutoResolveMetadataConflicts(ctx context.Context, tx *sql return true, nil } - // Branch creates a new branch func (s *DoltStore) Branch(ctx context.Context, name string) (retErr error) { ctx, span := doltTracer.Start(ctx, "dolt.branch", diff --git a/internal/templates/agents/defaults/agents.md.tmpl b/internal/templates/agents/defaults/agents.md.tmpl index 1efabff9ef..2bff688adb 100644 --- a/internal/templates/agents/defaults/agents.md.tmpl +++ b/internal/templates/agents/defaults/agents.md.tmpl @@ -36,7 +36,7 @@ cp -rf source dest # NOT: cp -r source dest - `apt-get` - use `-y` flag - `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var - + ## Issue Tracking with bd (beads) **IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. @@ -44,7 +44,7 @@ cp -rf source dest # NOT: cp -r source dest ### Why bd? - Dependency-aware: Track blockers and relationships between issues -- Version-controlled: Built on Dolt with cell-level merge +- Git-friendly: Dolt-powered version control with native sync - Agent-optimized: JSON output, ready work detection, discovered-from links - Prevents duplicate tracking systems and confusion @@ -103,10 +103,10 @@ bd close bd-42 --reason "Completed" --json ### Auto-Sync -bd automatically syncs with git: +bd automatically syncs via Dolt: -- Exports to `.beads/issues.jsonl` after changes (5s debounce) -- Imports from JSONL when newer (e.g., after `git pull`) +- Each write auto-commits to Dolt history +- Use `bd dolt push`/`bd dolt pull` for remote sync - No manual export/import needed! ### Important Rules diff --git a/internal/templates/agents/defaults/beads-section-minimal.md b/internal/templates/agents/defaults/beads-section-minimal.md new file mode 100644 index 0000000000..d20fd25865 --- /dev/null +++ b/internal/templates/agents/defaults/beads-section-minimal.md @@ -0,0 +1,44 @@ +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/internal/templates/agents/render.go b/internal/templates/agents/render.go new file mode 100644 index 0000000000..958aed9139 --- /dev/null +++ b/internal/templates/agents/render.go @@ -0,0 +1,101 @@ +package agents + +import ( + "crypto/sha256" + _ "embed" + "fmt" + "strings" +) + +// Profile identifies which template variant to render. +type Profile string + +const ( + // ProfileFull is the command-heavy profile for hookless agents (Codex, Factory, Mux, etc.). + ProfileFull Profile = "full" + // ProfileMinimal is the pointer-only profile for hook-enabled agents (Claude, Gemini). + ProfileMinimal Profile = "minimal" +) + +//go:embed defaults/beads-section-minimal.md +var beadsSectionMinimal string + +// SectionMeta holds metadata parsed from a BEGIN BEADS INTEGRATION marker. +type SectionMeta struct { + Profile Profile + Hash string +} + +// RenderSection returns the beads integration section for the given profile, +// wrapped in markers that include profile and hash metadata for freshness detection. +func RenderSection(profile Profile) string { + body := templateBody(profile) + hash := computeHash(body) + beginMarker := fmt.Sprintf("", profile, hash) + return beginMarker + "\n" + body + "\n\n" +} + +// CurrentHash returns the hash of the current template body for a profile. +// Callers can compare this against a parsed marker's hash to detect staleness. +func CurrentHash(profile Profile) string { + return computeHash(templateBody(profile)) +} + +// ParseMarker parses a BEGIN BEADS INTEGRATION marker line and returns its metadata. +// Returns nil if the line is not a valid begin marker. +// Supports both legacy (no metadata) and new (profile + hash) formats. +func ParseMarker(line string) *SectionMeta { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "" + inner := strings.TrimPrefix(line, "") + inner = strings.TrimSpace(inner) + + if inner == "" { + // Legacy format: + return meta + } + + // Parse key:value pairs + for _, part := range strings.Fields(inner) { + k, v, ok := strings.Cut(part, ":") + if !ok { + continue + } + switch k { + case "profile": + meta.Profile = Profile(v) + case "hash": + meta.Hash = v + } + } + + return meta +} + +// templateBody returns the raw body content (without markers) for a profile. +func templateBody(profile Profile) string { + switch profile { + case ProfileMinimal: + return strings.TrimRight(beadsSectionMinimal, "\n") + default: + // Full profile uses the same body as the legacy beads-section.md + // Strip the existing markers from the embedded content + body := strings.TrimRight(beadsSection, "\n") + body = strings.TrimPrefix(body, "\n") + body = strings.TrimSuffix(body, "\n") + return body + } +} + +// computeHash returns the first 8 hex chars of the SHA-256 of the body. +func computeHash(body string) string { + h := sha256.Sum256([]byte(body)) + return fmt.Sprintf("%x", h[:4]) +} diff --git a/internal/templates/agents/render_test.go b/internal/templates/agents/render_test.go new file mode 100644 index 0000000000..b2d5d67240 --- /dev/null +++ b/internal/templates/agents/render_test.go @@ -0,0 +1,210 @@ +package agents + +import ( + "strings" + "testing" +) + +func TestProfileConstants(t *testing.T) { + if ProfileFull != "full" { + t.Errorf("ProfileFull = %q, want %q", ProfileFull, "full") + } + if ProfileMinimal != "minimal" { + t.Errorf("ProfileMinimal = %q, want %q", ProfileMinimal, "minimal") + } +} + +func TestRenderSectionFull(t *testing.T) { + section := RenderSection(ProfileFull) + if section == "" { + t.Fatal("RenderSection(full) returned empty string") + } + + // Must start with begin marker containing profile and hash metadata + if !strings.HasPrefix(section, "") { + t.Error("section should end with end marker") + } + + // Full profile must contain command references + for _, want := range []string{"bd create", "bd update", "bd close", "bd ready", "discovered-from"} { + if !strings.Contains(section, want) { + t.Errorf("full profile missing %q", want) + } + } +} + +func TestRenderSectionMinimal(t *testing.T) { + section := RenderSection(ProfileMinimal) + if section == "" { + t.Fatal("RenderSection(minimal) returned empty string") + } + + // Must start with begin marker containing profile and hash metadata + if !strings.HasPrefix(section, "", + want: SectionMeta{Profile: ProfileFull, Hash: "a1b2c3d4"}, + wantOK: true, + }, + { + name: "new format minimal profile", + line: "", + want: SectionMeta{Profile: ProfileMinimal, Hash: "deadbeef"}, + wantOK: true, + }, + { + name: "legacy format (no metadata)", + line: "", + want: SectionMeta{Profile: "", Hash: ""}, + wantOK: true, + }, + { + name: "not a marker", + line: "## Some heading", + wantOK: false, + }, + { + name: "end marker (not begin)", + line: "", + wantOK: false, + }, + { + name: "empty string", + line: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseMarker(tt.line) + if tt.wantOK { + if got == nil { + t.Fatal("ParseMarker returned nil, expected non-nil") + } + if got.Profile != tt.want.Profile { + t.Errorf("Profile = %q, want %q", got.Profile, tt.want.Profile) + } + if got.Hash != tt.want.Hash { + t.Errorf("Hash = %q, want %q", got.Hash, tt.want.Hash) + } + } else { + if got != nil { + t.Errorf("ParseMarker returned %+v, expected nil", got) + } + } + }) + } +} + +func TestIsStaleFreshness(t *testing.T) { + // Render a section and parse its marker — should not be stale + section := RenderSection(ProfileFull) + firstLine := strings.SplitN(section, "\n", 2)[0] + meta := ParseMarker(firstLine) + if meta == nil { + t.Fatal("ParseMarker returned nil for rendered section") + } + + currentHash := CurrentHash(ProfileFull) + if meta.Hash != currentHash { + t.Errorf("rendered hash %q != current hash %q (should be fresh)", meta.Hash, currentHash) + } + + // Legacy marker (no hash) should be considered stale + legacyMeta := ParseMarker("") + if legacyMeta == nil { + t.Fatal("ParseMarker returned nil for legacy marker") + } + if legacyMeta.Hash == currentHash { + t.Error("legacy marker with empty hash should not match current hash") + } +}