Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 49 additions & 24 deletions cmd/mindspec/hook.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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{}
Expand All @@ -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)
Expand All @@ -82,44 +88,63 @@ 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 != "" {
source = s
}
}

// 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)")
Expand Down
22 changes: 8 additions & 14 deletions cmd/mindspec/instruct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions internal/hook/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"time"

"github.com/mrmaxsteel/mindspec/internal/phase"
"github.com/mrmaxsteel/mindspec/internal/state"
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions internal/instruct/instruct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions internal/next/beads.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/next/next_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
61 changes: 45 additions & 16 deletions internal/phase/derive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading