feat(prime): add --stealth flag for flush-only workflow

Add a `--stealth` flag to `bd prime` that outputs a simplified
workflow using only `bd sync --flush-only`, omitting all git
operations (commit, push, pull).

This addresses use cases where git operations need to be deferred
or handled separately from the bd workflow (e.g. bd init --stealth),
where committing files is may not desired as part of the Claude
conversation.

In stealth mode, the close protocol shows only the flush step.

Includes tests for current and existing functionality.

To make testing easier,
refactor output functions to accept `io.Writer` parameters
instead of writing directly to `os.Stdout`,
and convert `isEphemeralBranch` from a function to a
variable for stubbing.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Abhinav Gupta
2025-12-03 17:53:11 -08:00
parent c9eeecf0c3
commit 09a9ffa922
2 changed files with 140 additions and 14 deletions

View File

@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -13,8 +14,9 @@ import (
)
var (
primeFullMode bool
primeMCPMode bool
primeFullMode bool
primeMCPMode bool
primeStealthMode bool
)
var primeCmd = &cobra.Command{
@@ -47,8 +49,8 @@ agents from forgetting bd workflow after context compaction.`,
mcpMode = true
}
// Output workflow context (adaptive based on MCP)
if err := outputPrimeContext(mcpMode); err != nil {
// Output workflow context (adaptive based on MCP and stealth mode)
if err := outputPrimeContext(os.Stdout, mcpMode, primeStealthMode); err != nil {
// Suppress all errors - silent exit with success
// Never write to stderr (breaks Windows compatibility)
os.Exit(0)
@@ -59,6 +61,7 @@ agents from forgetting bd workflow after context compaction.`,
func init() {
primeCmd.Flags().BoolVar(&primeFullMode, "full", false, "Force full CLI output (ignore MCP detection)")
primeCmd.Flags().BoolVar(&primeMCPMode, "mcp", false, "Force MCP mode (minimal output)")
primeCmd.Flags().BoolVar(&primeStealthMode, "stealth", false, "Stealth mode (no git operations, flush only)")
rootCmd.AddCommand(primeCmd)
}
@@ -104,7 +107,7 @@ func isMCPActive() bool {
}
// isEphemeralBranch detects if current branch has no upstream (ephemeral/local-only)
func isEphemeralBranch() bool {
var isEphemeralBranch = func() bool {
// git rev-parse --abbrev-ref --symbolic-full-name @{u}
// Returns error code 128 if no upstream configured
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
@@ -113,19 +116,22 @@ func isEphemeralBranch() bool {
}
// outputPrimeContext outputs workflow context in markdown format
func outputPrimeContext(mcpMode bool) error {
func outputPrimeContext(w io.Writer, mcpMode bool, stealthMode bool) error {
if mcpMode {
return outputMCPContext()
return outputMCPContext(w, stealthMode)
}
return outputCLIContext()
return outputCLIContext(w, stealthMode)
}
// outputMCPContext outputs minimal context for MCP users
func outputMCPContext() error {
func outputMCPContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch()
var closeProtocol string
if ephemeral {
if stealthMode {
// Stealth mode: only flush to JSONL as there's nothing to commit.
closeProtocol = "Before saying \"done\": bd sync --flush-only"
} else if ephemeral {
closeProtocol = "Before saying \"done\": git status → git add → bd sync --from-main → git commit (no push - ephemeral branch)"
} else {
closeProtocol = "Before saying \"done\": git status → git add → bd sync → git commit → bd sync → git push"
@@ -143,12 +149,12 @@ func outputMCPContext() error {
Start: Check ` + "`ready`" + ` tool for available work.
`
fmt.Print(context)
_, _ = fmt.Fprint(w, context)
return nil
}
// outputCLIContext outputs full CLI reference for non-MCP users
func outputCLIContext() error {
func outputCLIContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch()
var closeProtocol string
@@ -156,7 +162,17 @@ func outputCLIContext() error {
var syncSection string
var completingWorkflow string
if ephemeral {
if stealthMode {
// Stealth mode: only flush to JSONL, no git operations
closeProtocol = `[ ] bd sync --flush-only (export beads to JSONL only)`
syncSection = `### Sync & Collaboration
- ` + "`bd sync --flush-only`" + ` - Export to JSONL`
completingWorkflow = `**Completing work:**
` + "```bash" + `
bd close <id1> <id2> ... # Close all completed issues at once
bd sync --flush-only # Export to JSONL
` + "```"
} else if ephemeral {
closeProtocol = `[ ] 1. git status (check what changed)
[ ] 2. git add <files> (stage code changes)
[ ] 3. bd sync --from-main (pull beads updates from main)
@@ -258,6 +274,6 @@ bd create --title="Write tests for X" --type=task
bd dep add beads-yyy beads-xxx # Tests depend on Feature (Feature blocks tests)
` + "```" + `
`
fmt.Print(context)
_, _ = fmt.Fprint(w, context)
return nil
}

110
cmd/bd/prime_test.go Normal file
View File

@@ -0,0 +1,110 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestOutputContextFunction(t *testing.T) {
tests := []struct {
name string
mcpMode bool
stealthMode bool
ephemeralMode bool
expectText []string
rejectText []string
}{
{
name: "CLI Normal (non-ephemeral)",
mcpMode: false,
stealthMode: false,
ephemeralMode: false,
expectText: []string{"Beads Workflow Context", "bd sync", "git push"},
rejectText: []string{"bd sync --flush-only", "--from-main"},
},
{
name: "CLI Normal (ephemeral)",
mcpMode: false,
stealthMode: false,
ephemeralMode: true,
expectText: []string{"Beads Workflow Context", "bd sync --from-main", "ephemeral branch"},
rejectText: []string{"bd sync --flush-only", "git push"},
},
{
name: "CLI Stealth",
mcpMode: false,
stealthMode: true,
ephemeralMode: false, // stealth mode overrides ephemeral detection
expectText: []string{"Beads Workflow Context", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"},
},
{
name: "MCP Normal (non-ephemeral)",
mcpMode: true,
stealthMode: false,
ephemeralMode: false,
expectText: []string{"Beads Issue Tracker Active", "bd sync", "git push"},
rejectText: []string{"bd sync --flush-only", "--from-main"},
},
{
name: "MCP Normal (ephemeral)",
mcpMode: true,
stealthMode: false,
ephemeralMode: true,
expectText: []string{"Beads Issue Tracker Active", "bd sync --from-main", "ephemeral branch"},
rejectText: []string{"bd sync --flush-only", "git push"},
},
{
name: "MCP Stealth",
mcpMode: true,
stealthMode: true,
ephemeralMode: false, // stealth mode overrides ephemeral detection
expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubIsEphemeralBranch(tt.ephemeralMode)()
var buf bytes.Buffer
err := outputPrimeContext(&buf, tt.mcpMode, tt.stealthMode)
if err != nil {
t.Fatalf("outputPrimeContext failed: %v", err)
}
output := buf.String()
for _, expected := range tt.expectText {
if !strings.Contains(output, expected) {
t.Errorf("Expected text not found: %s", expected)
}
}
for _, rejected := range tt.rejectText {
if strings.Contains(output, rejected) {
t.Errorf("Unexpected text found: %s", rejected)
}
}
})
}
}
// stubIsEphemeralBranch temporarily replaces isEphemeralBranch
// with a stub returning returnValue.
//
// Returns a function to restore the original isEphemeralBranch.
// Usage:
//
// defer stubIsEphemeralBranch(true)()
func stubIsEphemeralBranch(isEphem bool) func() {
original := isEphemeralBranch
isEphemeralBranch = func() bool {
return isEphem
}
return func() {
isEphemeralBranch = original
}
}