diff --git a/cmd/mindspec/hook.go b/cmd/mindspec/hook.go index c049279..b8204fb 100644 --- a/cmd/mindspec/hook.go +++ b/cmd/mindspec/hook.go @@ -1,12 +1,15 @@ package main import ( + "context" "fmt" "os" "os/exec" "strings" + "time" "github.com/mrmaxsteel/mindspec/internal/hook" + "github.com/mrmaxsteel/mindspec/internal/instruct" "github.com/spf13/cobra" ) @@ -39,6 +42,15 @@ Use --format to override protocol auto-detection.`, return fmt.Errorf("unknown hook %q (use --list to see available hooks)", name) } + // session-start is special: it writes session metadata and + // runs instruct, rather than the pass/block/warn pattern. + // Handle it before ParseInput to avoid blocking on stdin + // (io.ReadAll hangs if the caller doesn't close stdin). + if name == "session-start" { + inp := hook.ParseInputNonBlocking(os.Stdin) + return runSessionStartHook(inp) + } + inp, proto, err := hook.ParseInput(os.Stdin) if err != nil { inp = &hook.Input{} @@ -55,12 +67,6 @@ Use --format to override protocol auto-detection.`, return fmt.Errorf("unknown format %q (use claude or copilot)", hookFormat) } - // session-start is special: it writes session metadata and - // runs instruct, rather than the pass/block/warn pattern. - if name == "session-start" { - return runSessionStartHook(inp) - } - st := hook.ReadState() result := hook.Run(name, inp, st, true) code := hook.Emit(result, proto) @@ -82,9 +88,8 @@ func isValidHook(name string) bool { // runSessionStartHook handles the session-start hook: // 1. Writes session metadata (source + timestamp) from stdin JSON -// 2. Runs `mindspec instruct` and prints its output +// 2. Emits mode guidance (fast-path inline for protected branches, subprocess fallback) func runSessionStartHook(inp *hook.Input) error { - // Extract source from stdin JSON (Claude Code sends {"source": "startup|clear|resume|compact"}) source := "unknown" if inp.Raw != nil { if s, ok := inp.Raw["source"].(string); ok && s != "" { @@ -92,34 +97,54 @@ func runSessionStartHook(inp *hook.Input) error { } } - // Find root for running commands root, err := findRoot() if err != nil { - // Can't find root — still try instruct fmt.Fprintln(os.Stderr, "warning: could not find mindspec root") + return nil } // Write session metadata (best-effort) - if root != "" { - writeSession := exec.Command(os.Args[0], "state", "write-session", "--source="+source) - writeSession.Dir = root - writeSession.Stderr = os.Stderr - _ = writeSession.Run() + writeSession := exec.Command(os.Args[0], "state", "write-session", "--source="+source) + writeSession.Dir = root + writeSession.Stderr = os.Stderr + _ = writeSession.Run() + + // Pre-warm dolt server in the background so bd calls don't pay the + // 5s+ cold-start penalty. Idempotent — no-ops if already running. + prewarmDolt(root) + + // Fast path: on a protected branch (main/master), emit idle guidance + // inline without spawning a subprocess or querying beads. + if output, ok := instruct.RenderIdleIfProtected(root); ok { + fmt.Print(output) + return nil } - // Run instruct and emit its output - instruct := exec.Command(os.Args[0], "instruct") - if root != "" { - instruct.Dir = root - } - instruct.Stdout = os.Stdout - instruct.Stderr = os.Stderr - if err := instruct.Run(); err != nil { - fmt.Fprintln(os.Stderr, "mindspec instruct unavailable — run make build") + // Slow path: spawn instruct subprocess with a timeout for non-protected branches. + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "instruct") + cmd.Dir = root + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + fmt.Fprintln(os.Stderr, "warning: mindspec instruct timed out (bd/dolt may be slow — try `bd dolt start`)") + } else { + fmt.Fprintln(os.Stderr, "mindspec instruct unavailable — run make build") + } } return nil } +// prewarmDolt starts the dolt server in the background (fire-and-forget, ~300ms). +// Subsequent bd calls connect to the running server instead of cold-starting (~50ms vs ~5.5s). +func prewarmDolt(root string) { + cmd := exec.Command("bd", "dolt", "start") + cmd.Dir = root + _ = cmd.Start() +} + func init() { hookCmd.Flags().Bool("list", false, "List available hook names") hookCmd.Flags().StringVar(&hookFormat, "format", "", "Override protocol auto-detection (claude or copilot)") diff --git a/cmd/mindspec/instruct.go b/cmd/mindspec/instruct.go index fff6eb8..ff928b6 100644 --- a/cmd/mindspec/instruct.go +++ b/cmd/mindspec/instruct.go @@ -5,8 +5,6 @@ import ( "os" "github.com/mrmaxsteel/mindspec/internal/bead" - "github.com/mrmaxsteel/mindspec/internal/config" - "github.com/mrmaxsteel/mindspec/internal/gitutil" "github.com/mrmaxsteel/mindspec/internal/guard" "github.com/mrmaxsteel/mindspec/internal/instruct" "github.com/mrmaxsteel/mindspec/internal/phase" @@ -44,6 +42,14 @@ If multiple active specs exist, the command fails with a list of candidates.`, mainRoot = localRoot } + // Protected branch check FIRST: main/master → always idle. + // This must run before guard/worktree checks which query beads (slow dolt cold start). + if specFlag == "" { + if _, ok := instruct.RenderIdleIfProtected(mainRoot); ok { + return handleNoState(mainRoot, format) + } + } + // CWD redirect: if running from main with an active worktree, // emit ONLY the redirect message — no normal guidance. if wtPath := guard.ActiveWorktreePath(mainRoot); wtPath != "" && guard.IsMainCWD(mainRoot) { @@ -57,18 +63,6 @@ If multiple active specs exist, the command fails with a list of candidates.`, return nil } - // Protected branch check: main/master → always idle (focus file is stale). - if specFlag == "" { - branch, _ := gitutil.CurrentBranch() - cfg, cfgErr := config.Load(mainRoot) - if cfgErr != nil { - cfg = config.DefaultConfig() - } - if branch != "" && cfg.IsProtectedBranch(branch) { - return handleNoState(mainRoot, format) - } - } - // ADR-0023: derive state from beads, not focus files. // First try resolver for spec targeting, then use phase context. specID, resolveErr := resolve.ResolveTarget(mainRoot, specFlag) diff --git a/internal/hook/hook.go b/internal/hook/hook.go index 253c4cd..4696794 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "time" "github.com/mrmaxsteel/mindspec/internal/phase" "github.com/mrmaxsteel/mindspec/internal/state" @@ -51,6 +52,49 @@ var Names = []string{ "session-start", } +// ParseInputNonBlocking reads available stdin without blocking. +// If stdin is a terminal or has no data ready within 100ms, returns an empty Input. +// Used by session-start which must not hang if the caller doesn't close stdin. +// Note: on timeout, the reader goroutine is intentionally leaked — the process +// exits shortly after so this is harmless in practice. +func ParseInputNonBlocking(r io.Reader) *Input { + f, ok := r.(*os.File) + if !ok { + return &Input{} + } + stat, err := f.Stat() + if err != nil { + return &Input{} + } + // If stdin is a terminal (CharDevice), skip reading. + if stat.Mode()&os.ModeCharDevice != 0 { + return &Input{} + } + // It's a pipe/file — read with a short timeout in case the pipe is open but empty. + type readResult struct { + data []byte + err error + } + ch := make(chan readResult, 1) + go func() { + data, err := io.ReadAll(f) + ch <- readResult{data, err} + }() + select { + case res := <-ch: + if res.err != nil || len(res.data) == 0 { + return &Input{} + } + var raw map[string]any + if err := json.Unmarshal(res.data, &raw); err != nil { + return &Input{} + } + return &Input{Raw: raw} + case <-time.After(100 * time.Millisecond): + return &Input{} + } +} + // ParseInput reads stdin JSON and auto-detects the protocol. func ParseInput(r io.Reader) (*Input, Protocol, error) { data, err := io.ReadAll(r) diff --git a/internal/instruct/instruct.go b/internal/instruct/instruct.go index 429da05..2c89a84 100644 --- a/internal/instruct/instruct.go +++ b/internal/instruct/instruct.go @@ -12,6 +12,7 @@ import ( "github.com/mrmaxsteel/mindspec/internal/config" "github.com/mrmaxsteel/mindspec/internal/contextpack" + "github.com/mrmaxsteel/mindspec/internal/gitutil" "github.com/mrmaxsteel/mindspec/internal/state" "github.com/mrmaxsteel/mindspec/internal/workspace" ) @@ -145,6 +146,30 @@ func Render(ctx *Context) (string, error) { return result, nil } +// RenderIdleIfProtected checks if the current branch is protected (main/master) +// and returns rendered idle guidance if so. Returns ("", false) if the branch is +// not protected or on error. This avoids beads queries for the common main-branch case. +func RenderIdleIfProtected(root string) (string, bool) { + branch, err := gitutil.CurrentBranch() + if err != nil || branch == "" { + return "", false + } + cfg, err := config.Load(root) + if err != nil { + cfg = config.DefaultConfig() + } + if !cfg.IsProtectedBranch(branch) { + return "", false + } + mc := &state.Focus{Mode: state.ModeIdle} + ctx := BuildContext(root, mc) + output, err := Render(ctx) + if err != nil { + return "", false + } + return output, true +} + // RenderJSON produces structured JSON output. func RenderJSON(ctx *Context) (string, error) { guidance, err := Render(ctx) diff --git a/internal/next/beads.go b/internal/next/beads.go index ea1651f..5415aba 100644 --- a/internal/next/beads.go +++ b/internal/next/beads.go @@ -156,10 +156,15 @@ func ResolveActiveBead(root, specID string) (string, error) { return "", nil } -// ClaimBead marks a bead as in_progress via bd update. +// ClaimBead atomically claims a bead via bd update --claim. +// Fails if the bead was already claimed by another agent, preventing +// two concurrent agents from working on the same bead. func ClaimBead(id string) error { - _, err := runBDCombFn("update", id, "--status=in_progress") - return err + out, err := runBDCombFn("update", id, "--claim") + if err != nil { + return fmt.Errorf("claim failed (may already be claimed): %s", strings.TrimSpace(string(out))) + } + return nil } // FetchBeadByID retrieves a single bead by its ID via bd show --json. diff --git a/internal/next/next_test.go b/internal/next/next_test.go index 427e047..a52a6be 100644 --- a/internal/next/next_test.go +++ b/internal/next/next_test.go @@ -330,7 +330,7 @@ func TestClaimBead_CallsRunBDCombined(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(capturedArgs) != 3 || capturedArgs[0] != "update" || capturedArgs[1] != "bead-abc" || capturedArgs[2] != "--status=in_progress" { + if len(capturedArgs) != 3 || capturedArgs[0] != "update" || capturedArgs[1] != "bead-abc" || capturedArgs[2] != "--claim" { t.Errorf("unexpected args: %v", capturedArgs) } } diff --git a/internal/phase/derive.go b/internal/phase/derive.go index a8fe3c6..30959d2 100644 --- a/internal/phase/derive.go +++ b/internal/phase/derive.go @@ -149,7 +149,7 @@ func readStoredPhase(epicID string) string { return "" } phase, ok := raw.(string) - if !ok || !state.IsValidMode(phase) { + if !ok || !state.IsValidPhase(phase) { return "" } return phase @@ -203,14 +203,23 @@ func DerivePhaseFromChildren(children []ChildInfo) string { return state.ModeReview } + // Some closed + some open, none in_progress → implement (between beads). + // Closed beads prove implementation has started; the agent is between + // completing one bead and claiming the next. + if totalClosed > 0 { + return state.ModeImplement + } + // All children open (none claimed) → plan - // Some closed, some open, none in_progress → plan (next bead ready) return state.ModePlan } -// DiscoverActiveSpecs queries beads for all open epics and derives phase for each. +// DiscoverActiveSpecs queries beads for open/in_progress epics and derives phase for each. +// Only queries non-closed statuses to minimize bd calls and avoid dolt server contention +// (closed epics with mindspec_phase: done are not active; legacy closed epics without the +// marker are an edge case that callers like FindEpicBySpecID handle separately). func DiscoverActiveSpecs() ([]ActiveSpec, error) { - epics, err := queryEpics() + epics, err := queryActiveEpics() if err != nil { return nil, err } @@ -242,16 +251,6 @@ func DiscoverActiveSpecs() ([]ActiveSpec, error) { // Fallback for pre-080 epics: derive from children. children := queryChildren(epic.ID) - // Check done marker for closed epics before deriving phase. - if strings.EqualFold(epic.Status, "closed") { - if hasDoneMarker(epic.ID) { - continue // spec lifecycle complete - } - if len(children) == 0 { - continue // orphan: closed epic with no children - } - } - phase := DerivePhaseFromChildren(children) if phase == state.ModeDone { continue @@ -419,15 +418,45 @@ func extractPhaseFromMetadata(epic EpicInfo) string { return "" } phase, ok := raw.(string) - if !ok || !state.IsValidMode(phase) { + if !ok || !state.IsValidPhase(phase) { return "" } return phase } +// queryActiveEpics returns only open and in_progress epics (2 bd calls). +// Used by DiscoverActiveSpecs where closed epics are not needed. +func queryActiveEpics() ([]EpicInfo, error) { + var allEpics []EpicInfo + seen := map[string]bool{} + var lastErr error + for _, status := range []string{"open", "in_progress"} { + out, err := listJSONFn("--type=epic", "--status="+status) + if err != nil { + lastErr = err + continue + } + var epics []EpicInfo + if err := json.Unmarshal(out, &epics); err != nil { + lastErr = err + continue + } + for _, e := range epics { + if !seen[e.ID] { + seen[e.ID] = true + allEpics = append(allEpics, e) + } + } + } + if len(allEpics) == 0 && lastErr != nil { + return nil, fmt.Errorf("bd list --type=epic failed: %w", lastErr) + } + return allEpics, nil +} + func queryEpics() ([]EpicInfo, error) { // Query all statuses: bd list --type=epic defaults to open only, - // but phase derivation needs closed epics too (e.g. impl approve). + // but phase derivation needs closed epics too (e.g. impl approve, FindEpicBySpecID). var allEpics []EpicInfo seen := map[string]bool{} var lastErr error diff --git a/internal/phase/derive_test.go b/internal/phase/derive_test.go index bc8415e..4fe26ff 100644 --- a/internal/phase/derive_test.go +++ b/internal/phase/derive_test.go @@ -62,13 +62,13 @@ func TestDerivePhaseFromChildren(t *testing.T) { want: state.ModeReview, }, { - name: "some closed, some open, none in_progress → plan (next bead ready)", + name: "some closed, some open, none in_progress → implement (between beads)", children: []ChildInfo{ {ID: "b1", Status: "closed"}, {ID: "b2", Status: "open"}, {ID: "b3", Status: "closed"}, }, - want: state.ModePlan, + want: state.ModeImplement, }, { name: "some closed, one in_progress → implement", diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 97032fa..758ee41 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -3,6 +3,7 @@ package resolve import ( "encoding/json" "path/filepath" + "strings" "testing" "github.com/mrmaxsteel/mindspec/internal/phase" @@ -16,10 +17,23 @@ func stubActiveEpics(t *testing.T, epics []phase.EpicInfo, childrenByEpic map[st for _, e := range epics { epicByID[e.ID] = e } - // queryEpics and queryChildren use listJSONFn + // queryEpics/queryActiveEpics and queryChildren use listJSONFn restoreList := phase.SetListJSONForTest(func(args ...string) ([]byte, error) { for _, a := range args { if a == "--type=epic" { + // Filter epics by --status arg to match queryActiveEpics behavior + for _, s := range args { + if strings.HasPrefix(s, "--status=") { + status := strings.TrimPrefix(s, "--status=") + var filtered []phase.EpicInfo + for _, e := range epics { + if strings.EqualFold(e.Status, status) { + filtered = append(filtered, e) + } + } + return json.Marshal(filtered) + } + } return json.Marshal(epics) } } diff --git a/internal/state/state.go b/internal/state/state.go index ac78a3d..09d50e8 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -88,7 +88,8 @@ func BeadWorktreePath(specWorktree, beadID string) string { return filepath.Join(specWorktree, ".worktrees", "worktree-"+beadID) } -// IsValidMode reports whether mode is a recognized lifecycle mode. +// IsValidMode reports whether mode is a recognized operating mode +// (excludes terminal states like "done"). func IsValidMode(mode string) bool { for _, m := range ValidModes { if m == mode { @@ -97,3 +98,10 @@ func IsValidMode(mode string) bool { } return false } + +// IsValidPhase reports whether phase is a recognized lifecycle phase, +// including terminal states like "done" that are valid in metadata +// but not valid operating modes. +func IsValidPhase(phase string) bool { + return IsValidMode(phase) || phase == ModeDone +}