diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index cebc02c..5d2d449 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -73,6 +73,10 @@ func main() { os.Exit(1) } os.Exit(runState(os.Args[2:])) + case "hold": + os.Exit(runHold(os.Args[2:])) + case "unhold": + os.Exit(runUnhold(os.Args[2:])) case "herald": os.Exit(runHerald(os.Args[2:])) case "dashboard": @@ -97,9 +101,14 @@ Hook commands (called by Claude Code hooks, read stdin): hook file-track Record file touches in quest tome Agent/lead commands: - gate status Show current phase, prereqs, pending state + gate status Show current phase, prereqs, pending/held state gate approve Approve a pending gate (advances to next phase) gate reject Reject a pending gate (clears pending, keeps phase) + hold Hold (pause) a quest — blocks Edit/Write/Bash/Agent/Skill/NotebookEdit + --dir DIR Worktree directory (required) + --reason MSG Reason for holding + unhold Unhold (resume) a held quest + --dir DIR Worktree directory (required) tome show [--json] Show quest tome (phases, gates, files touched) status [--json] Scan worktrees and show fellowship recovery status eagles Scan quest health and write eagles report @@ -194,12 +203,6 @@ func runHook(name string) int { return 0 } - s, err := state.Load(statePath) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 2 - } - input, err := hooks.ParseInput(os.Stdin) if err != nil { switch name { @@ -212,74 +215,110 @@ func runHook(name string) int { } dir := filepath.Dir(filepath.Dir(statePath)) // strip /quest-state.json - questName := s.QuestName - if questName == "" { - questName = filepath.Base(dir) - } - - var result hooks.HookResult - stateChanged := false + // Read-only hooks: no locking needed, just load and check. switch name { case "gate-guard": - result = hooks.GateGuard(s, input) - case "gate-submit": - prevPhase := s.Phase - sr := hooks.GateSubmit(s, input) - result = hooks.HookResult{Block: sr.Block, Message: sr.Message} - stateChanged = sr.StateChanged - if sr.StateChanged && !sr.Block { - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - hooks.RecordGateSubmitted(tomePath, prevPhase, s.Phase != prevPhase) - herald.Announce(dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.GateSubmitted, - Phase: s.Phase, - Detail: "Gate submitted for review", - }) - } - case "gate-prereq": - stateChanged = hooks.GatePrereq(s, input) - if stateChanged { - herald.Announce(dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.LembasCompleted, - Phase: s.Phase, - Detail: "Lembas skill completed", - }) + s, err := state.Load(statePath) + if err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 } - case "metadata-track": - stateChanged = hooks.MetadataTrack(s, input) - if stateChanged { - herald.Announce(dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.MetadataUpdated, - Phase: s.Phase, - Detail: "Task metadata updated", - }) + result := hooks.GateGuard(s, input) + if result.Block { + fmt.Fprintln(os.Stderr, result.Message) + return 2 } + return 0 case "completion-guard": - result = hooks.CompletionGuard(s, input) + s, err := state.Load(statePath) + if err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 + } + result := hooks.CompletionGuard(s, input) if !result.Block && input.ToolInput.Status == "completed" { tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") hooks.MarkTomeCompleted(tomePath) } + if result.Block { + fmt.Fprintln(os.Stderr, result.Message) + return 2 + } + return 0 case "file-track": + s, err := state.Load(statePath) + if err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 + } tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") hooks.FileTrack(s, input, tomePath) - default: - fmt.Fprintf(os.Stderr, "fellowship: unknown hook %q\n", name) - return 2 + return 0 } - if stateChanged { - if err := state.Save(statePath, s); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: failed to save state: %v\n", err) - return 2 + // Mutating hooks: use WithLock for atomic load→mutate→save. + var result hooks.HookResult + if err := state.WithLock(statePath, func(s *state.State) error { + questName := s.QuestName + if questName == "" { + questName = filepath.Base(dir) + } + + switch name { + case "gate-submit": + prevPhase := s.Phase + sr := hooks.GateSubmit(s, input) + result = hooks.HookResult{Block: sr.Block, Message: sr.Message} + if sr.StateChanged && !sr.Block { + tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") + hooks.RecordGateSubmitted(tomePath, prevPhase, s.Phase != prevPhase) + herald.Announce(dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.GateSubmitted, + Phase: s.Phase, + Detail: "Gate submitted for review", + }) + } + if !sr.StateChanged { + return state.ErrNoSave + } + case "gate-prereq": + changed := hooks.GatePrereq(s, input) + if changed { + herald.Announce(dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.LembasCompleted, + Phase: s.Phase, + Detail: "Lembas skill completed", + }) + } else { + return state.ErrNoSave + } + case "metadata-track": + changed := hooks.MetadataTrack(s, input) + if changed { + herald.Announce(dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.MetadataUpdated, + Phase: s.Phase, + Detail: "Task metadata updated", + }) + } else { + return state.ErrNoSave + } + default: + fmt.Fprintf(os.Stderr, "fellowship: unknown hook %q\n", name) + result = hooks.HookResult{Block: true, Message: fmt.Sprintf("unknown hook %q", name)} + return state.ErrNoSave } + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 } if result.Block { @@ -306,6 +345,10 @@ func runGate(args []string) int { case "status": fmt.Printf("Phase: %s\n", s.Phase) fmt.Printf("Pending: %v\n", s.GatePending) + fmt.Printf("Held: %v\n", s.Held) + if s.HeldReason != nil { + fmt.Printf("Reason: %s\n", *s.HeldReason) + } fmt.Printf("Lembas: %v\n", s.LembasCompleted) fmt.Printf("Metadata: %v\n", s.MetadataUpdated) if s.GateID != nil { @@ -314,22 +357,25 @@ func runGate(args []string) int { return 0 case "approve": - if !s.GatePending { - fmt.Fprintln(os.Stderr, "No gate pending") - return 1 - } - nextPhase, err := state.NextPhase(s.Phase) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - prevPhase := s.Phase - s.GatePending = false - s.Phase = nextPhase - s.GateID = nil - s.LembasCompleted = false - s.MetadataUpdated = false - if err := state.Save(statePath, s); err != nil { + var prevPhase, nextPhase, questName string + if err := state.WithLock(statePath, func(s *state.State) error { + if !s.GatePending { + return fmt.Errorf("no gate pending") + } + np, err := state.NextPhase(s.Phase) + if err != nil { + return err + } + prevPhase = s.Phase + nextPhase = np + questName = s.QuestName + s.GatePending = false + s.Phase = nextPhase + s.GateID = nil + s.LembasCompleted = false + s.MetadataUpdated = false + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -339,46 +385,48 @@ func runGate(args []string) int { tome.RecordPhase(c, prevPhase) tome.Save(tomePath, c) gateDir := filepath.Dir(filepath.Dir(statePath)) - gateQuestName := s.QuestName - if gateQuestName == "" { - gateQuestName = filepath.Base(gateDir) + if questName == "" { + questName = filepath.Base(gateDir) } now := time.Now().UTC().Format(time.RFC3339) herald.Announce(gateDir, herald.Tiding{ - Timestamp: now, Quest: gateQuestName, Type: herald.GateApproved, + Timestamp: now, Quest: questName, Type: herald.GateApproved, Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), }) herald.Announce(gateDir, herald.Tiding{ - Timestamp: now, Quest: gateQuestName, Type: herald.PhaseTransition, + Timestamp: now, Quest: questName, Type: herald.PhaseTransition, Phase: nextPhase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, nextPhase), }) fmt.Printf("Gate approved. Phase advanced to %s.\n", nextPhase) return 0 case "reject": - if !s.GatePending { - fmt.Fprintln(os.Stderr, "No gate pending") - return 1 - } - s.GatePending = false - s.GateID = nil - if err := state.Save(statePath, s); err != nil { + var phase, questName string + if err := state.WithLock(statePath, func(s *state.State) error { + if !s.GatePending { + return fmt.Errorf("no gate pending") + } + s.GatePending = false + s.GateID = nil + phase = s.Phase + questName = s.QuestName + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") c := tome.LoadOrCreate(tomePath) - tome.RecordGate(c, s.Phase, "rejected") + tome.RecordGate(c, phase, "rejected") tome.Save(tomePath, c) rejDir := filepath.Dir(filepath.Dir(statePath)) - rejQuestName := s.QuestName - if rejQuestName == "" { - rejQuestName = filepath.Base(rejDir) + if questName == "" { + questName = filepath.Base(rejDir) } herald.Announce(rejDir, herald.Tiding{ Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: rejQuestName, Type: herald.GateRejected, - Phase: s.Phase, Detail: fmt.Sprintf("Gate rejected for %s", s.Phase), + Quest: questName, Type: herald.GateRejected, + Phase: phase, Detail: fmt.Sprintf("Gate rejected for %s", phase), }) fmt.Println("Gate rejected. Teammate unblocked to address feedback.") return 0 @@ -390,6 +438,100 @@ func runGate(args []string) int { } +func runHold(args []string) int { + fs := flag.NewFlagSet("hold", flag.ExitOnError) + dir := fs.String("dir", "", "Worktree directory (required)") + reason := fs.String("reason", "", "Reason for holding the quest") + fs.Parse(args) + + if *dir == "" { + fmt.Fprintln(os.Stderr, "usage: fellowship hold --dir [--reason \"message\"]") + return 1 + } + + statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") + var questName, phase string + if err := state.WithLock(statePath, func(s *state.State) error { + if s.Held { + return fmt.Errorf("quest is already held") + } + s.Held = true + if *reason != "" { + s.HeldReason = reason + } + questName = s.QuestName + phase = s.Phase + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + if questName == "" { + questName = filepath.Base(*dir) + } + detail := "Quest held" + if *reason != "" { + detail += ": " + *reason + } + herald.Announce(*dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.QuestHeld, + Phase: phase, + Detail: detail, + }) + + fmt.Printf("Quest held.%s\n", func() string { + if *reason != "" { + return " Reason: " + *reason + } + return "" + }()) + return 0 +} + +func runUnhold(args []string) int { + fs := flag.NewFlagSet("unhold", flag.ExitOnError) + dir := fs.String("dir", "", "Worktree directory (required)") + fs.Parse(args) + + if *dir == "" { + fmt.Fprintln(os.Stderr, "usage: fellowship unhold --dir ") + return 1 + } + + statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") + var questName, phase string + if err := state.WithLock(statePath, func(s *state.State) error { + if !s.Held { + return fmt.Errorf("quest is not held") + } + s.Held = false + s.HeldReason = nil + questName = s.QuestName + phase = s.Phase + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + if questName == "" { + questName = filepath.Base(*dir) + } + herald.Announce(*dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.QuestUnheld, + Phase: phase, + Detail: "Quest unheld — resumed", + }) + + fmt.Println("Quest unheld.") + return 0 +} + func runInit() int { root := gitRootOrCwd() dir := filepath.Join(root, datadir.Name()) diff --git a/cli/internal/dashboard/fellowship.go b/cli/internal/dashboard/fellowship.go index 87e4014..535c327 100644 --- a/cli/internal/dashboard/fellowship.go +++ b/cli/internal/dashboard/fellowship.go @@ -7,9 +7,9 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/filelock" "github.com/justinjdev/fellowship/cli/internal/errand" "github.com/justinjdev/fellowship/cli/internal/state" ) @@ -200,10 +200,10 @@ func WithStateLock(path string, fn func(s *FellowshipState) error) error { } defer lockFile.Close() - if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + if err := filelock.Lock(lockFile.Fd()); err != nil { return fmt.Errorf("acquiring lock: %w", err) } - defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + defer filelock.Unlock(lockFile.Fd()) s, err := LoadFellowshipState(path) if err != nil { diff --git a/cli/internal/filelock/filelock_unix.go b/cli/internal/filelock/filelock_unix.go new file mode 100644 index 0000000..f54b25b --- /dev/null +++ b/cli/internal/filelock/filelock_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package filelock + +import "syscall" + +// Lock acquires an exclusive lock on the given file descriptor. +func Lock(fd uintptr) error { + return syscall.Flock(int(fd), syscall.LOCK_EX) +} + +// Unlock releases the lock on the given file descriptor. +func Unlock(fd uintptr) error { + return syscall.Flock(int(fd), syscall.LOCK_UN) +} diff --git a/cli/internal/filelock/filelock_windows.go b/cli/internal/filelock/filelock_windows.go new file mode 100644 index 0000000..466af02 --- /dev/null +++ b/cli/internal/filelock/filelock_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package filelock + +import ( + "fmt" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFileEx = modkernel32.NewProc("UnlockFileEx") +) + +const lockfileExclusiveLock = 0x00000002 + +// Lock acquires an exclusive lock on the given file descriptor. +func Lock(fd uintptr) error { + h := syscall.Handle(fd) + ol := new(syscall.Overlapped) + r1, _, err := procLockFileEx.Call( + uintptr(h), + lockfileExclusiveLock, + 0, + 1, 0, + uintptr(unsafe.Pointer(ol)), + ) + if r1 == 0 { + return fmt.Errorf("LockFileEx: %w", err) + } + return nil +} + +// Unlock releases the lock on the given file descriptor. +func Unlock(fd uintptr) error { + h := syscall.Handle(fd) + ol := new(syscall.Overlapped) + r1, _, err := procUnlockFileEx.Call( + uintptr(h), + 0, + 1, 0, + uintptr(unsafe.Pointer(ol)), + ) + if r1 == 0 { + return fmt.Errorf("UnlockFileEx: %w", err) + } + return nil +} diff --git a/cli/internal/herald/herald.go b/cli/internal/herald/herald.go index fa3dcea..b055288 100644 --- a/cli/internal/herald/herald.go +++ b/cli/internal/herald/herald.go @@ -22,6 +22,8 @@ const ( PhaseTransition TidingType = "phase_transition" LembasCompleted TidingType = "lembas_completed" MetadataUpdated TidingType = "metadata_updated" + QuestHeld TidingType = "quest_held" + QuestUnheld TidingType = "quest_unheld" ) // Tiding represents a single quest event. diff --git a/cli/internal/hooks/guard.go b/cli/internal/hooks/guard.go index 06603d1..e0d4f25 100644 --- a/cli/internal/hooks/guard.go +++ b/cli/internal/hooks/guard.go @@ -13,6 +13,18 @@ type HookResult struct { } func GateGuard(s *state.State, input *HookInput) HookResult { + if s.Held { + msg := "Quest is held — paused by the lead." + if s.HeldReason != nil { + msg += " Reason: " + *s.HeldReason + } + msg += " Wait for the lead to unhold before taking any action." + return HookResult{ + Block: true, + Message: msg, + } + } + if s.GatePending { return HookResult{ Block: true, diff --git a/cli/internal/hooks/guard_test.go b/cli/internal/hooks/guard_test.go index e879743..ac2ec96 100644 --- a/cli/internal/hooks/guard_test.go +++ b/cli/internal/hooks/guard_test.go @@ -1,6 +1,7 @@ package hooks import ( + "strings" "testing" "github.com/justinjdev/fellowship/cli/internal/state" @@ -84,3 +85,37 @@ func TestGateGuard_PendingBlocksEvenDuringLatePhase(t *testing.T) { t.Error("gate_pending should block even during Implement") } } + +func TestGateGuard_BlocksWhenHeld(t *testing.T) { + s := &state.State{Phase: "Implement", Held: true} + input := &HookInput{ToolInput: ToolInput{Command: "ls"}} + result := GateGuard(s, input) + if !result.Block { + t.Error("should block when quest is held") + } +} + +func TestGateGuard_BlocksWhenHeldWithReason(t *testing.T) { + reason := "file conflict with quest-auth" + s := &state.State{Phase: "Implement", Held: true, HeldReason: &reason} + input := &HookInput{ToolInput: ToolInput{Command: "ls"}} + result := GateGuard(s, input) + if !result.Block { + t.Error("should block when quest is held") + } + if !strings.Contains(result.Message, reason) { + t.Errorf("message should include held reason, got: %s", result.Message) + } +} + +func TestGateGuard_HeldTakesPriorityOverGatePending(t *testing.T) { + s := &state.State{Phase: "Implement", Held: true, GatePending: true} + input := &HookInput{ToolInput: ToolInput{Command: "ls"}} + result := GateGuard(s, input) + if !result.Block { + t.Error("should block") + } + if !strings.Contains(result.Message, "held") { + t.Errorf("held should take priority over gate_pending, got: %s", result.Message) + } +} diff --git a/cli/internal/state/state.go b/cli/internal/state/state.go index 90a5398..e85656b 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -10,8 +10,13 @@ import ( "strings" "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/filelock" ) +// ErrNoSave can be returned from a WithLock callback to skip saving +// the state file while still releasing the lock without error. +var ErrNoSave = fmt.Errorf("no save needed") + type State struct { Version int `json:"version"` QuestName string `json:"quest_name"` @@ -23,6 +28,8 @@ type State struct { LembasCompleted bool `json:"lembas_completed"` MetadataUpdated bool `json:"metadata_updated"` AutoApproveGates []string `json:"auto_approve_gates"` + Held bool `json:"held"` + HeldReason *string `json:"held_reason"` } var phaseOrder = []string{"Onboard", "Research", "Plan", "Implement", "Review", "Complete"} @@ -75,6 +82,37 @@ func Save(path string, s *State) error { return nil } +// WithLock acquires an exclusive file lock, loads the state, calls fn to +// mutate it, and saves the result. The entire load→mutate→save is atomic with +// respect to other processes using the same lock. +func WithLock(path string, fn func(s *State) error) error { + lockPath := path + ".lock" + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("opening lock file: %w", err) + } + defer lockFile.Close() + + if err := filelock.Lock(lockFile.Fd()); err != nil { + return fmt.Errorf("acquiring lock: %w", err) + } + defer filelock.Unlock(lockFile.Fd()) + + s, err := Load(path) + if err != nil { + return err + } + + if err := fn(s); err != nil { + if err == ErrNoSave { + return nil + } + return err + } + + return Save(path, s) +} + func FindStateFile(fromDir string) (string, error) { root, err := gitRoot(fromDir) if err != nil { diff --git a/plugin/skills/fellowship/SKILL.md b/plugin/skills/fellowship/SKILL.md index c545bac..cfb31a7 100644 --- a/plugin/skills/fellowship/SKILL.md +++ b/plugin/skills/fellowship/SKILL.md @@ -36,92 +36,53 @@ User: "company: API work — quest: add endpoint, quest: add tests, scout: revie **Companies** group related quests and scouts for batch operations and progress tracking. A company is a lightweight grouping layer — it does not change how quests execute, only how they are organized and reported. -Quests produce code and PRs. Scouts produce research reports. Both can be added while others are in progress, after some finish, or all at once. - ### Load Config -At startup, read `~/.claude/fellowship.json` (the user's personal Claude directory) if it exists. This file contains user preferences for fellowship behavior that apply across all projects. If the file does not exist, all defaults apply. Merge the file contents with defaults — any key not present in the file uses the default value. +At startup, read `~/.claude/fellowship.json` (the user's personal Claude directory) if it exists. Merge with defaults — any key not present uses the default value. If the file does not exist, all defaults apply. **Config keys used by fellowship:** `branch.*` (branch naming), `worktree.*` (isolation), `gates.autoApprove` (gate routing), `pr.*` (PR creation), `palantir.*` (monitoring). See `/settings` for the full schema, defaults, and valid values. **IMPORTANT — gate defaults:** When no config file exists, or when `gates.autoApprove` is absent/empty, ALL gates surface to the user. No gates are auto-approved by default. Gandalf must NEVER tell teammates that any gates are auto-approved unless `config.gates.autoApprove` explicitly lists them. -**Example config (optional — only if the user wants auto-approval):** -```json -{ - "gates": { "autoApprove": ["Research", "Plan"] }, - "pr": { "draft": true } -} -``` -This is NOT the default. This is an opt-in configuration. Without this file, every gate requires user approval. - -If the user asks to set up or modify their config, invoke `/settings`. - ### Write Fellowship State -> **Note:** `.fellowship/` is the default data directory. Users can override it via `dataDir` in `~/.claude/fellowship.json`. All `fellowship` CLI commands resolve the correct directory automatically. When this document references `.fellowship/`, it means the configured data directory. +> **Note:** `.fellowship/` is the default data directory. Users can override it via `dataDir` in `~/.claude/fellowship.json`. All `fellowship` CLI commands resolve the correct directory automatically. -Initialize the fellowship state file using the CLI. The fellowship name comes from the `TeamCreate` name (e.g., `"fellowship-1709734200"`). This file is the primary recovery artifact — `/rekindle` uses it to reconstruct state after a crash. +Initialize the fellowship state file using the CLI: ```bash fellowship state init --dir --name ``` -**Add quest on spawn:** After spawning each quest, add it to the state file: +After spawning each quest/scout, add it to the state file: ```bash -fellowship state add-quest --dir --name --task "" [--branch ] [--task-id ] +fellowship state add-quest --dir --name --task "" [--branch ] [--task-id ] +fellowship state add-scout --dir --name --question "" [--task-id ] +fellowship state add-company --dir --name --quests q1,q2 --scouts s1 ``` -The worktree path is available after the quest runner reports back from Phase 0 (stored in task metadata as `worktree_path`). Update the quest entry when it becomes available: +Update quest entries when worktree path becomes available (from task metadata `worktree_path`): ```bash fellowship state update-quest --dir --name [--worktree ] [--branch ] [--task-id ] ``` -**Add scout on spawn:** After spawning each scout: - -```bash -fellowship state add-scout --dir --name --question "" [--task-id ] -``` - -**Companies:** When the user creates a company (e.g., `"company: API work — quest: add endpoint, quest: add tests, scout: review API docs"`), Gandalf records the company and spawns the quests and scouts as normal: - -```bash -fellowship state add-company --dir --name --quests quest-1,quest-2 --scouts scout-1 -``` - -**Show state:** To inspect the current fellowship state: - -```bash -fellowship state show --dir -``` - ### Discover Templates -At startup (or when spawning a quest), Gandalf discovers available templates from two directories, highest priority first: +At startup (or when spawning a quest), discover templates from two directories (project wins on collision): 1. **Project** — `.claude/fellowship-templates/` in the repo root 2. **User** — `~/.claude/fellowship-templates/` -No built-in templates ship with fellowship — templates are project-specific by design. Use `/scribe` to create templates that encode your team's actual conventions. Read all `.md` files from each directory. If the same filename exists in both tiers, project wins. Parse YAML frontmatter to extract `name`, `description`, and `keywords`. - -### Select Template for Quest +No built-in templates ship with fellowship. Use `/scribe` to create them. Parse YAML frontmatter for `name`, `description`, and `keywords`. -When spawning a quest, Gandalf selects a template: - -1. **Explicit:** If the user specified `template: ` in their quest request, use that template. If the named template doesn't exist, warn the user and proceed without a template. -2. **Auto-suggest:** If no explicit template, match the task description words against each template's `keywords` array. Pick the template with the most keyword matches. Ties go to the first match found. -3. **No match:** If no keywords match, proceed without a template. - -When a template is selected (explicitly or via auto-suggest), announce it when spawning: e.g., `"Using 'bugfix' template for this quest."` The user can course-correct but Gandalf doesn't block on confirmation. +**Template selection:** Explicit (`template: `) > auto-suggest (keyword matching) > no template. ### Gate Hook Propagation Plugin hooks only fire in Gandalf's session — teammates spawned via the Agent tool do not inherit them. A `SessionStart` hook in the plugin automatically creates `.claude/settings.json` with project-level hooks when the plugin loads. This ensures teammates inherit gate enforcement without any manual setup. -The installed hooks use absolute paths to the plugin's wrapper script (`fellowship.sh`), which ensures the Go CLI binary exists before executing hook commands. For worktrees, quest Phase 0 re-creates the file after `EnterWorktree` (see quest skill). - ### Spawn a Quest For each quest, Gandalf: @@ -131,195 +92,26 @@ For each quest, Gandalf: - `team_name`: the fellowship team name - `subagent_type: "general-purpose"` - `name`: `"quest-{n}"` or a descriptive name like `"quest-auth-bug"` - - Do NOT pass `isolation: "worktree"` — the teammate creates its own worktree during quest Phase 0, using the branch naming config. This avoids double-worktree conflicts and ensures config-resolved branch names are used. + - Do NOT pass `isolation: "worktree"` — the teammate creates its own worktree during quest Phase 0. -**Errand persistence:** After spawning a teammate, Gandalf writes the initial errands to `.fellowship/quest-errands.json` in the quest's worktree by running `fellowship errand init`. This creates a persistent record of what errands were assigned. To add errands to a running quest: `fellowship errand add --dir 'handle edge case X'`. To re-sling unfinished errands from a dead quest: read its errand file (`fellowship errand show --dir `), extract pending errands, and add them to a new quest's errand list (`fellowship errand add --dir "description"`). +**Errand persistence:** After spawning, write initial errands via `fellowship errand init --dir --quest --task "description"`. Add errands to running quests: `fellowship errand add --dir 'description'`. -**Errand CLI commands:** -- `fellowship errand init --dir --quest --task "description"` — create initial errand file -- `fellowship errand add --dir "description"` — add a new errand -- `fellowship errand update --dir ` — update an errand's status (pending, active, done, blocked) -- `fellowship errand list --dir ` — show all errands with status -- `fellowship errand show --dir ` — JSON output of the full errand list - -**Teammate spawn prompt:** - -``` -You are a quest runner in a fellowship coordinated by Gandalf (the lead). - -YOUR TASK: {task_description} - -INSTRUCTIONS: -1. Run /quest to execute this task through the full quest lifecycle -2. Quest Phase 0 will create your isolated worktree using the branch - naming config — make changes freely once isolation is set up -3. Gate handling — gates are enforced by plugin hooks via a state file - (.fellowship/quest-state.json). The hooks structurally block your tools - after gate submission. Here is how it works: - - Before EACH gate, you MUST: - a. Run /lembas to compress context (hooks verify this) - b. Run TaskUpdate(taskId: "{task_id}", metadata: {"phase": ""}) - to record your current phase (hooks verify this) - c. Send ONE gate checklist via SendMessage to the lead. - The message content MUST start with [GATE] — e.g.: - "[GATE] Research complete\n- [x] Key files identified..." - Messages without the [GATE] prefix are not detected as gates. - - After sending a gate message, your Edit/Write/Bash/Agent/Skill tools - are blocked by hooks until the lead approves. You cannot bypass this. - The lead approves by updating your state file — only the lead can - unblock you. - - {gate_config_override} - - NEVER send two gates in one message. - NEVER approve your own gates — only the lead can approve. - NEVER write "approved" or "proceeding" — that is the lead's language. -4. When /quest reaches Phase 5 (Complete), create a PR and message - the lead with the PR URL -5. If you get stuck or need a decision, message the lead -6. If you receive a shutdown request, respond immediately using - SendMessage with type "shutdown_response", approve: true, and - the request_id from the message. Do not just acknowledge in text. - -CONVENTIONS: -- Use conventional commits for all git commits (e.g., feat:, fix:, docs:, refactor:) - -BOUNDARIES: -- Stay in YOUR worktree. Do NOT read, write, or navigate into other - teammates' worktrees. Your working directory is your worktree root. -- Do NOT use MCP tools or external service integrations (Notion, Slack, - Jira, etc.) without first messaging the lead and getting explicit - approval. Your scope is local: code, tests, git, and the filesystem. -- Do NOT push branches, create PRs, or take any action visible to - others without lead approval (except at Phase 5 as instructed above). - -CONTEXT: -- Fellowship team: {team_name} -- Your quest: {quest_name} -- Your task ID: {task_id} -- Other active quests: {brief_list} -- PR config: {pr_config_line} -{template_guidance} -``` - -**Spawn prompt substitution rules:** - -Before sending the spawn prompt, Gandalf substitutes these placeholders with actual values: - -| Placeholder | Source | -|---|---| -| `{task_description}` | The quest task text from the user | -| `{task_id}` | Task ID returned by `TaskCreate` | -| `{team_name}` | The fellowship team name | -| `{quest_name}` | Descriptive name (e.g., `"quest-auth-bug"`) | -| `{brief_list}` | Comma-separated list of other active quest names | -| `{gate_config_override}` | See below | -| `{pr_config_line}` | If `config.pr` exists: `"draft=true, template=..."`. If not: `"default (not a draft, no template)"` | -| `{template_guidance}` | See below | - -**`{gate_config_override}` generation (read `config.gates.autoApprove` — default is empty):** -- **DEFAULT (no config, or `autoApprove` absent/empty):** substitute with `"All gates require lead approval. Do not proceed past any gate without receiving an explicit approval message from the lead."` — do NOT mention auto-approval in any form. -- **Only if `autoApprove` explicitly lists gate names** (e.g., `["Research", "Plan"]`): substitute with `"The following gates are auto-approved and hooks will advance your state automatically: Research, Plan. For all other gates, your tools are blocked until the lead approves."` - -**`{template_guidance}` generation:** -- **No template selected:** substitute with empty string (no extra content in spawn prompt) -- **Template selected:** substitute with: - ``` - TEMPLATE: "{template_name}" - At the start of each quest phase, invoke /lorebook to load - phase-specific guidance for this template. - ``` +**Spawn prompt:** See [resources/spawn-prompts.md](resources/spawn-prompts.md) for the full quest spawn prompt template and substitution rules. ### Spawn a Scout For each scout, Gandalf: -1. `TaskCreate` in the shared task list with the question and type "scout" -2. Spawn a teammate via the `Task` tool with: - - `team_name`: the fellowship team name - - `subagent_type: "fellowship:scout"` (uses the scout agent definition — tools are restricted to read-only source access + coordination + Write for research notes) - - `name`: `"scout-{n}"` or a descriptive name like `"scout-auth-analysis"` - - Do NOT pass `isolation: "worktree"` — scouts work in the main repo - -**Scout spawn prompt:** +1. `TaskCreate` with the question and type "scout" +2. Spawn via `Task` tool with `subagent_type: "fellowship:scout"`, no worktree isolation. -``` -You are a scout in a fellowship coordinated by Gandalf (the lead). - -YOUR QUESTION: {question} - -INSTRUCTIONS: -1. Run /scout to investigate this question -{routing_instruction} -2. Do NOT use MCP tools or external service integrations without - lead approval. - -CONTEXT: -- Fellowship team: {team_name} -- Your scout: {scout_name} -- Your task ID: {task_id} -- Other active tasks: {brief_list} -``` - -**Scout spawn prompt substitution rules:** - -Substitute `{team_name}`, `{task_id}`, `{brief_list}` as described in Spawn a Quest above. Additional scout-specific placeholders: - -| Placeholder | Source | -|---|---| -| `{scout_name}` | Descriptive name (e.g., `"scout-auth-analysis"`) | -| `{question}` | The scout question from the user | -| `{routing_instruction}` | See below | - -**`{routing_instruction}` generation:** -- **Default (no routing target):** substitute with empty string -- **If user specified a target** (e.g., `"scout: ... → send to quest-auth-bug"`): substitute with `"Also send your findings to {target_teammate} via SendMessage."` +**Spawn prompt:** See [resources/spawn-prompts.md](resources/spawn-prompts.md) for the scout spawn prompt template. ### Spawn Palantir -When `config.palantir.minQuests` or more quests are active (default: 2) and `config.palantir.enabled` is true (default), Gandalf spawns a palantir monitoring agent as a background teammate. Palantir watches quest progress, detects stuck agents, scope drift, and file conflicts, and alerts the lead. If `config.palantir.enabled` is false, skip palantir entirely. - -Spawn palantir via the `Task` tool with: -- `team_name`: the fellowship team name -- `subagent_type: "fellowship:palantir"` -- `name`: `"palantir"` - -**Palantir spawn prompt:** - -``` -You are the palantir — a background monitor for this fellowship. - -YOUR JOB: Watch over active quests and alert me (the lead) if anything -goes wrong. You never write code or run quests. - -MONITORING CHECKLIST: -1. Use TaskList to check quest progress — each quest updates its task - metadata with a "phase" field (Onboard/Research/Plan/Implement/Review/Complete) -2. Flag quests that appear stuck (phase hasn't advanced, no gate messages) -3. Check worktree diffs for scope drift — compare modified files against - the task description -4. Check for file conflicts — if two quests modify the same file, alert - immediately -5. Send all alerts to me via SendMessage with summary prefix "palantir:" - -ACTIVE QUESTS: -{quest_list_with_worktree_paths} - -TEAM: {team_name} - -BOUNDARIES: -- Read-only access to quest worktrees. Never modify files. -- Never modify task state. Use TaskList and TaskGet for reading only. -- If you receive a shutdown request, approve it immediately. -``` - -Only one palantir runs per fellowship. If quests drop below `config.palantir.minQuests` (default: 2), shut down palantir to save resources. If palantir detects an issue, Gandalf presents it to the user alongside the affected quest's context. +When `config.palantir.minQuests` or more quests are active (default: 2) and `config.palantir.enabled` is true (default), spawn a palantir monitoring agent. Only one palantir per fellowship. Shut down when quests drop below threshold. -### Monitor & Approve Gates - -See the Gate Handling section below. +**Spawn prompt:** See [resources/spawn-prompts.md](resources/spawn-prompts.md) for the palantir spawn prompt template. ### Disband @@ -327,54 +119,46 @@ When the user says "wrap up" or "disband": 1. Send `shutdown_request` to all active teammates (including palantir) 2. Synthesize a summary: quests completed, PR URLs, any open items -3. Run `fellowship uninstall` to remove gate hooks from `.claude/settings.json` (preserves other settings if present, removes the file if hooks were the only content) +3. Run `fellowship uninstall` to remove gate hooks from `.claude/settings.json` 4. Run `TeamDelete` to clean up ## Gate Handling -Each quest runs the full `/quest` lifecycle (6 phases with gates). Gates are enforced by a state machine — project-level hooks (installed during "Install Gate Hooks" at startup) block teammate tools based on phase and gate state. Only Gandalf can unblock a pending gate by writing to the teammate's state file. +Each quest runs the full `/quest` lifecycle (6 phases with gates). Gates are enforced by a state machine — project-level hooks block teammate tools based on phase and gate state. Only Gandalf can unblock a pending gate. -**DEFAULT: ALL gates surface to the user.** No gates are ever auto-approved unless `config.gates.autoApprove` explicitly lists them. When no config file exists or `autoApprove` is absent/empty, every gate must be presented to the user for approval. Gandalf must NEVER auto-approve a gate that is not listed in `config.gates.autoApprove`. +**DEFAULT: ALL gates surface to the user.** No gates are ever auto-approved unless `config.gates.autoApprove` explicitly lists them. Gandalf must NEVER auto-approve a gate that is not listed in `config.gates.autoApprove`. -| Gate | Default Handling | -|------|----------| -| Onboard → Research | Surface to user | -| Research → Plan | Surface to user | -| Plan → Implement | Surface to user | -| Implement → Review | Surface to user | -| Review → Complete | Surface to user | +**With `config.gates.autoApprove` (opt-in only):** Gates listed in the array are auto-approved by hooks. Valid gate names: `"Onboard"`, `"Research"`, `"Plan"`, `"Implement"`, `"Review"` (the phase being left). -**With `config.gates.autoApprove` (opt-in only):** Gates listed in the array are auto-approved — the hooks advance the teammate's state automatically without setting `gate_pending`. Valid gate names: `"Onboard"`, `"Research"`, `"Plan"`, `"Implement"`, `"Review"` (the phase the teammate is leaving). For example, `"autoApprove": ["Research", "Plan"]` means the Research→Plan and Plan→Implement transitions are auto-approved, while other gates still surface to the user. If a gate name is NOT in this array, it MUST surface to the user. +### Gate Approval Procedure -When a gate is auto-approved (per config): the hooks advance the teammate's phase automatically. Gandalf logs it (e.g., `"quest-2: Research gate auto-approved per config"`) but does NOT need to write to the state file. When a gate requires user approval (the default): the lead presents the gate summary with context and waits for the user's response before approving. +1. **Read worktree path:** `TaskGet(taskId)` → `metadata.worktree_path` +2. **Update state file:** `fellowship gate approve --dir ` +3. **Send approval message** to the teammate via SendMessage -Example (user-approved): `"quest-2 (rate limiting) reached Research → Plan gate [██░░░░ 1/5]. Research summary: [summary]. Approve?"` -Example (auto-approved): `"quest-2: Research gate auto-approved per config"` +### Gate Rejection Procedure -### Gate Approval Procedure +1. **Clear pending:** `fellowship gate reject --dir ` +2. **Send rejection message** with feedback +3. Teammate addresses feedback, re-runs prerequisites, resubmits -When Gandalf approves a non-auto-approved gate: +## Conflict Resolution -1. **Read worktree path:** `TaskGet(taskId: "")` → read `metadata.worktree_path` -2. **Update the state file** using the `fellowship` CLI to unblock the teammate: - ```bash - fellowship gate approve --dir - ``` - This advances the phase (Onboard→Research→Plan→Implement→Review→Complete), clears `gate_pending`, and resets prerequisites. -3. **Send approval message** to the teammate via SendMessage +When Palantir raises a file conflict alert, Gandalf follows the conflict resolution protocol: Pause (`fellowship hold --dir [--reason "..."]`) → Assess (real vs incidental) → Resolve (sequence/partition/merge) → Resume (`fellowship unhold --dir `). -This is the structural enforcement — saying "approved" in text does nothing. The teammate's hooks read `gate_pending` from the state file on every tool call. Only this Bash-tool file write unblocks them. +See [resources/conflict-resolution.md](resources/conflict-resolution.md) for the full protocol. -### Gate Rejection Procedure +## Lead Behavior + +Gandalf's decision tree and event handling rules — reactive (teammate events), proactive (user commands), gate tracking, and gate discipline. + +See [resources/lead-behavior.md](resources/lead-behavior.md) for the full behavior specification. -When Gandalf rejects a gate (or the user rejects): +## Progress Tracking -1. **Clear `gate_pending`** using the `fellowship` CLI (rejects without advancing phase): - ```bash - fellowship gate reject --dir - ``` -2. **Send rejection message** to the teammate via SendMessage with feedback -3. The teammate addresses the feedback, runs `/lembas` and updates metadata again, then resubmits the gate +Status report format, phase-to-progress mappings, and company grouping. + +See [resources/progress-tracking.md](resources/progress-tracking.md) for details. ## Gandalf's Voice @@ -386,173 +170,23 @@ Gandalf speaks with the character of Gandalf the Grey — wise, occasionally wry |--------|------| | Approving a gate | "You shall pass." | | Rejecting a gate | "You shall not pass! Not yet." + feedback | -| Spawning a quest | "I will not say: do not weep; for not all tears are an evil. But I will say: go now, and do not tarry." | -| Quest completed | "You bow to no one." or "Well done. Even the very wise cannot see all ends." | +| Spawning a quest | "Go now, and do not tarry." | +| Quest completed | "You bow to no one." | | Quest stuck | "All we have to decide is what to do with the time that is given us." | -| Respawning a failed quest | "Gandalf? Yes... that is what they used to call me. I am Gandalf the White. And I come back to you now, at the turn of the tide." | +| Respawning | "I am Gandalf the White. And I come back to you now, at the turn of the tide." | | Status report | "The board is set, the pieces are moving." | -| Starting the fellowship | "The Fellowship of the Code is formed. You shall be the Fellowship of the Bug-fix." (or feature, refactor, etc.) | -| Wrapping up / disbanding | "I will not say: do not weep; for not all tears are an evil." or "Well, I'm back." | -| Teammate asking for help | "A wizard is never late, nor is he early. He arrives precisely when he means to." | -| Spawning a scout | "The wise speak only of what they know." | -| Scout completed | "All that is gold does not glitter — but this knowledge shines bright." | -| Scout found issues | "There is nothing like looking, if you want to find something." | -| Palantir alert | "The palantir is a dangerous tool, Saruman." or "I see you." | - -Keep it brief — one line, not a monologue. The quotes should accent the coordination, not replace it. Functional information always comes first; the quote is flavor. - -## Lead Behavior (Gandalf's Job) - -```dot -digraph gandalf { - "Event received" [shape=doublecircle]; - "From teammate?" [shape=diamond]; - "From user?" [shape=diamond]; - "Gate message?" [shape=diamond]; - "Quest completed?" [shape=diamond]; - "Quest stuck?" [shape=diamond]; - "Surface gate to user, WAIT" [shape=box]; - "Relay user decision to teammate" [shape=box]; - "Record PR URL, mark done, report" [shape=box]; - "Report error, offer respawn" [shape=box]; - "No action (idle is normal)" [shape=box]; - "quest: {desc}?" [shape=diamond]; - "Spawn teammate in worktree" [shape=box]; - "scout: {question}?" [shape=diamond]; - "Spawn scout teammate" [shape=box]; - "approve/reject?" [shape=diamond]; - "Relay to teammate" [shape=box]; - "status?" [shape=diamond]; - "Present progress report" [shape=box]; - "wrap up?" [shape=diamond]; - "Shutdown all, summarize, TeamDelete" [shape=box]; - "Relay message to teammate" [shape=box]; - - "Event received" -> "From teammate?"; - "From teammate?" -> "Gate message?" [label="yes"]; - "From teammate?" -> "From user?" [label="no"]; - "Gate message?" -> "Surface gate to user, WAIT" [label="yes"]; - "Surface gate to user, WAIT" -> "Relay user decision to teammate"; - "Gate message?" -> "Quest completed?" [label="no"]; - "Quest completed?" -> "Record PR URL, mark done, report" [label="yes"]; - "Quest completed?" -> "Quest stuck?" [label="no"]; - "Quest stuck?" -> "Report error, offer respawn" [label="yes"]; - "Quest stuck?" -> "No action (idle is normal)" [label="no"]; - "From user?" -> "quest: {desc}?" [label="yes"]; - "quest: {desc}?" -> "Spawn teammate in worktree" [label="yes"]; - "quest: {desc}?" -> "scout: {question}?" [label="no"]; - "scout: {question}?" -> "Spawn scout teammate" [label="yes"]; - "scout: {question}?" -> "approve/reject?" [label="no"]; - "approve/reject?" -> "Relay to teammate" [label="yes"]; - "approve/reject?" -> "status?" [label="no"]; - "status?" -> "Present progress report" [label="yes"]; - "status?" -> "wrap up?" [label="no"]; - "wrap up?" -> "Shutdown all, summarize, TeamDelete" [label="yes"]; - "wrap up?" -> "Relay message to teammate" [label="no"]; -} -``` - -### Reactive (responding to teammate events) - -- **Gate message received** → check `config.gates.autoApprove` (default: empty — no auto-approvals). If the specific gate name is explicitly listed in the config, auto-approve and relay. Otherwise (including when no config exists), surface to user for approval — never auto-approve by default. After handling the gate, send a "check" message to palantir (if active) to trigger a monitoring sweep. **Track the gate** — increment the gate count for this teammate (see Gate Tracking below). -- **Quest completed** → **FIRST verify gate completeness** (see Gate Tracking below). If the teammate has not sent all expected gates, reject the completion and demand the missing gates. Only after all gates are accounted for: record PR URL, mark task done via `TaskUpdate`, report to user. -- **Quest stuck/errored** → report to user with context (phase, error), offer respawn -- **Teammate idle** → normal, no action needed - -### Gate Tracking - -Gandalf maintains a gate count per teammate. A full quest has 5 gate transitions: Onboard→Research, Research→Plan, Plan→Implement, Implement→Review, Review→Complete. Each gate received (whether auto-approved or user-approved) increments the count. - -**Before accepting quest completion**, Gandalf verifies: -1. The teammate's gate count equals 5 (all transitions completed) -2. The teammate's phase metadata shows "Complete" - -If either check fails, Gandalf rejects the completion: -- Message the teammate: "Gate discipline violation — you have completed {N}/5 gates. You must submit gates for all phase transitions before completing. Missing: {list of missing transitions}." -- Do NOT mark the task as done -- Do NOT record a PR URL -- Report the violation to the user - -This is defense-in-depth — the `completion-guard` hook also mechanically blocks `TaskUpdate(status: "completed")` unless the state file phase is "Complete", but Gandalf's verification catches cases where the hooks can't (e.g., state file corruption, manual overrides). - -### Proactive (responding to user commands) - -- **"quest: {desc}"** → spawn new quest teammate (see Spawn a Quest). After spawning, send a "check" message to palantir (if active) with the updated quest list. -- **"scout: {question}"** → spawn new scout teammate (see Spawn a Scout). Scouts don't count toward palantir's quest threshold. -- **"status"** → read task list (including metadata), present structured progress report (see Progress Tracking below) -- **"approve" / "reject"** → relay to the relevant teammate -- **"approve all gates for {company_name}"** → batch-approve all pending gates in the named company using `fellowship company approve `. Report which quests were approved. -- **"cancel quest-N"** → send `shutdown_request` to teammate, preserve worktree -- **"tell quest-N to ..."** → relay message to specific teammate via `SendMessage` -- **"wrap up" / "disband"** → shutdown all teammates, synthesize summary, `TeamDelete` - -### Progress Tracking - -Gandalf maintains awareness of quest progress through two mechanisms: - -1. **Task metadata**: Each teammate updates their task's `phase` metadata field at phase transitions via `TaskUpdate`. Gandalf reads this via `TaskList` when reporting status. -2. **Gate messages**: Gate transition messages from teammates provide the most recent context for each quest. - -When the user asks for "status" or Gandalf proactively reports progress: - -``` -## Fellowship Status - -| Task | Type | Phase | Progress | -|------|------|-------|----------| -| quest-auth-bug | Quest | Implement | ████░░ 3/5 | -| quest-rate-limit | Quest | Research | █░░░░░ 1/5 | -| scout-auth-analysis | Scout | Validating | ██░░ 2/3 | - -**Quests:** 2 active | **Scouts:** 1 active | **Completed:** 0 -``` - -When companies are defined, group quests by company in the status report: - -``` -## Company: API Work (2/3 quests in Implement+) - -| Task | Type | Phase | Progress | -|------|------|-------|----------| -| quest-add-endpoint | Quest | Implement | ████░░ 3/5 | -| quest-add-tests | Quest | Research | █░░░░░ 1/5 | -| scout-review-api | Scout | Investigating | █░░ 1/3 | - -## Ungrouped - -| Task | Type | Phase | Progress | -|------|------|-------|----------| -| quest-other-task | Quest | Plan | ██░░░░ 2/5 | -``` - -Quest phase-to-progress mapping: -- Onboard = 0/5, Research = 1/5, Plan = 2/5, Implement = 3/5, Review = 4/5, Complete = 5/5 - -Scout phase-to-progress mapping: -- Investigating = 1/3, Validating = 2/3, Done = 3/3 - -- Use filled/empty block characters for visual progress -- Pull phase from task metadata `phase` field via `TaskList` -- Pull last gate context from the most recent gate message or teammate update - -### Gate Discipline - -Never combine gate approvals. Approve one gate at a time. Each gate response triggers exactly one transition — never tell a teammate to skip ahead through multiple gates. When a teammate sends a gate message, surface it (or auto-approve per config), then wait for the next gate to arrive before acting on it. - -### What Gandalf does NOT do +| Starting fellowship | "The Fellowship of the Code is formed." | +| Disbanding | "Well, I'm back." | +| Palantir alert | "The palantir is a dangerous tool, Saruman." | -- Write code -- Run quests itself -- Make architectural decisions -- Merge PRs (user's responsibility) -- Skip or combine gate approvals +Keep it brief — one line, not a monologue. Functional information always comes first; the quote is flavor. ## Edge Cases - **Quest fails:** Report to user with context (which phase, what went wrong). Offer to respawn. Worktree is preserved. - **Respawn procedure:** Spawn a new teammate with the same task description, but add to the spawn prompt: `"You are resuming a failed quest. Your working directory is already set to the existing worktree at {worktree_path}. Skip worktree creation in quest Phase 0 — you're already isolated. Check .fellowship/checkpoint.md for a checkpoint from the previous attempt."` Set the new teammate's working directory to the failed quest's worktree path. - **Direct teammate access:** Through Gandalf ("tell quest-2 to skip the logger refactor") or direct via Shift+Down to message the teammate. -- **Session death:** Worktrees survive but coordination is lost. Teammates are orphaned. To resume: start a new fellowship, and for each incomplete quest use the respawn procedure above pointing at the preserved worktree. Each worktree's `.fellowship/checkpoint.md` has the last known state. If a teammate was stuck in `gate_pending: true` when the session died, the respawn procedure resets this automatically. For manual recovery without respawn, reject the pending gate: `fellowship gate reject --dir ` +- **Session death:** Worktrees survive but coordination is lost. To resume: start a new fellowship, use respawn procedure for each incomplete quest. Each worktree's `.fellowship/checkpoint.md` has the last known state. For manual recovery: `fellowship gate reject --dir ` ## Key Principles diff --git a/plugin/skills/fellowship/resources/conflict-resolution.md b/plugin/skills/fellowship/resources/conflict-resolution.md new file mode 100644 index 0000000..c90cdae --- /dev/null +++ b/plugin/skills/fellowship/resources/conflict-resolution.md @@ -0,0 +1,49 @@ +# Conflict Resolution Protocol + +When Palantir raises a file conflict alert (two quests modifying the same file), Gandalf follows this protocol: + +## 1. Pause + +Hold the later quest immediately to prevent further divergence: + +```bash +fellowship hold --dir --reason "file conflict with : " +``` + +This structurally blocks the held quest's Edit/Write/Bash/Agent/Skill/NotebookEdit tools via the gate-guard hook. The quest cannot proceed until unheld. + +Notify the held quest via SendMessage explaining the hold. + +## 2. Assess + +Read both quests' plans and diffs to determine the conflict type: + +- **Real conflict**: Both quests modify the same function, section, or logical unit. Merging will require manual resolution. +- **Incidental overlap**: Both quests touch the same file but different, non-overlapping sections. Merging is straightforward. + +## 3. Resolve + +Pick one of three strategies based on the assessment: + +**Sequence** (for real conflicts): Let the earlier quest finish first. After it merges, rebase the held quest's worktree onto the updated main branch, then unhold. This is the safest strategy — no concurrent modifications to the same code. + +**Partition** (for real conflicts that can be separated): Assign non-overlapping regions of the file to each quest. Update both quests' plans via SendMessage to clarify boundaries, then unhold. Only use this when the conflict is in clearly separable sections. + +**Merge** (for incidental overlaps): Let both quests proceed. The later quest to merge handles any trivial conflicts during its PR. Unhold immediately with instructions to be aware of the overlap. + +## 4. Resume + +Unhold the paused quest: + +```bash +fellowship unhold --dir +``` + +Send a message to the resumed quest via SendMessage with: +- The resolution strategy chosen +- Any updated instructions (e.g., "avoid modifying lines 50-80 in auth.go — quest-1 owns that section") +- Context about what the other quest is doing in the shared file + +## Hold Outside Conflicts + +Hold/unhold can also be used outside the conflict protocol — for example, to pause a quest while waiting for a user decision or external dependency. The mechanism is general-purpose. diff --git a/plugin/skills/fellowship/resources/lead-behavior.md b/plugin/skills/fellowship/resources/lead-behavior.md new file mode 100644 index 0000000..6bd59c9 --- /dev/null +++ b/plugin/skills/fellowship/resources/lead-behavior.md @@ -0,0 +1,98 @@ +# Lead Behavior (Gandalf's Job) + +```dot +digraph gandalf { + "Event received" [shape=doublecircle]; + "From teammate?" [shape=diamond]; + "From user?" [shape=diamond]; + "Gate message?" [shape=diamond]; + "Quest completed?" [shape=diamond]; + "Quest stuck?" [shape=diamond]; + "Surface gate to user, WAIT" [shape=box]; + "Relay user decision to teammate" [shape=box]; + "Record PR URL, mark done, report" [shape=box]; + "Report error, offer respawn" [shape=box]; + "No action (idle is normal)" [shape=box]; + "quest: {desc}?" [shape=diamond]; + "Spawn teammate in worktree" [shape=box]; + "scout: {question}?" [shape=diamond]; + "Spawn scout teammate" [shape=box]; + "approve/reject?" [shape=diamond]; + "Relay to teammate" [shape=box]; + "status?" [shape=diamond]; + "Present progress report" [shape=box]; + "wrap up?" [shape=diamond]; + "Shutdown all, summarize, TeamDelete" [shape=box]; + "Relay message to teammate" [shape=box]; + + "Event received" -> "From teammate?"; + "From teammate?" -> "Gate message?" [label="yes"]; + "From teammate?" -> "From user?" [label="no"]; + "Gate message?" -> "Surface gate to user, WAIT" [label="yes"]; + "Surface gate to user, WAIT" -> "Relay user decision to teammate"; + "Gate message?" -> "Quest completed?" [label="no"]; + "Quest completed?" -> "Record PR URL, mark done, report" [label="yes"]; + "Quest completed?" -> "Quest stuck?" [label="no"]; + "Quest stuck?" -> "Report error, offer respawn" [label="yes"]; + "Quest stuck?" -> "No action (idle is normal)" [label="no"]; + "From user?" -> "quest: {desc}?" [label="yes"]; + "quest: {desc}?" -> "Spawn teammate in worktree" [label="yes"]; + "quest: {desc}?" -> "scout: {question}?" [label="no"]; + "scout: {question}?" -> "Spawn scout teammate" [label="yes"]; + "scout: {question}?" -> "approve/reject?" [label="no"]; + "approve/reject?" -> "Relay to teammate" [label="yes"]; + "approve/reject?" -> "status?" [label="no"]; + "status?" -> "Present progress report" [label="yes"]; + "status?" -> "wrap up?" [label="no"]; + "wrap up?" -> "Shutdown all, summarize, TeamDelete" [label="yes"]; + "wrap up?" -> "Relay message to teammate" [label="no"]; +} +``` + +## Reactive (responding to teammate events) + +- **Gate message received** → check `config.gates.autoApprove` (default: empty — no auto-approvals). If the specific gate name is explicitly listed in the config, auto-approve and relay. Otherwise (including when no config exists), surface to user for approval — never auto-approve by default. After handling the gate, send a "check" message to palantir (if active) to trigger a monitoring sweep. **Track the gate** — increment the gate count for this teammate (see Gate Tracking below). +- **Quest completed** → **FIRST verify gate completeness** (see Gate Tracking below). If the teammate has not sent all expected gates, reject the completion and demand the missing gates. Only after all gates are accounted for: record PR URL, mark task done via `TaskUpdate`, report to user. +- **Quest stuck/errored** → report to user with context (phase, error), offer respawn +- **Teammate idle** → normal, no action needed + +## Gate Tracking + +Gandalf maintains a gate count per teammate. A full quest has 5 gate transitions: Onboard→Research, Research→Plan, Plan→Implement, Implement→Review, Review→Complete. Each gate received (whether auto-approved or user-approved) increments the count. + +**Before accepting quest completion**, Gandalf verifies: +1. The teammate's gate count equals 5 (all transitions completed) +2. The teammate's phase metadata shows "Complete" + +If either check fails, Gandalf rejects the completion: +- Message the teammate: "Gate discipline violation — you have completed {N}/5 gates. You must submit gates for all phase transitions before completing. Missing: {list of missing transitions}." +- Do NOT mark the task as done +- Do NOT record a PR URL +- Report the violation to the user + +This is defense-in-depth — the `completion-guard` hook also mechanically blocks `TaskUpdate(status: "completed")` unless the state file phase is "Complete", but Gandalf's verification catches cases where the hooks can't (e.g., state file corruption, manual overrides). + +## Proactive (responding to user commands) + +- **"quest: {desc}"** → spawn new quest teammate (see Spawn a Quest). After spawning, send a "check" message to palantir (if active) with the updated quest list. +- **"scout: {question}"** → spawn new scout teammate (see Spawn a Scout). Scouts don't count toward palantir's quest threshold. +- **"status"** → read task list (including metadata), present structured progress report (see [progress-tracking.md](progress-tracking.md)) +- **"approve" / "reject"** → relay to the relevant teammate +- **"approve all gates for {company_name}"** → batch-approve all pending gates in the named company using `fellowship company approve `. Report which quests were approved. +- **"hold quest-N"** → `fellowship hold --dir [--reason "..."]`, notify teammate via SendMessage +- **"unhold quest-N"** → `fellowship unhold --dir `, notify teammate via SendMessage with updated instructions +- **"cancel quest-N"** → send `shutdown_request` to teammate, preserve worktree +- **"tell quest-N to ..."** → relay message to specific teammate via `SendMessage` +- **"wrap up" / "disband"** → shutdown all teammates, synthesize summary, `TeamDelete` + +## Gate Discipline + +Never combine gate approvals. Approve one gate at a time. Each gate response triggers exactly one transition — never tell a teammate to skip ahead through multiple gates. When a teammate sends a gate message, surface it (or auto-approve per config), then wait for the next gate to arrive before acting on it. + +## What Gandalf does NOT do + +- Write code +- Run quests itself +- Make architectural decisions +- Merge PRs (user's responsibility) +- Skip or combine gate approvals diff --git a/plugin/skills/fellowship/resources/progress-tracking.md b/plugin/skills/fellowship/resources/progress-tracking.md new file mode 100644 index 0000000..c45356e --- /dev/null +++ b/plugin/skills/fellowship/resources/progress-tracking.md @@ -0,0 +1,54 @@ +# Progress Tracking + +Gandalf maintains awareness of quest progress through two mechanisms: + +1. **Task metadata**: Each teammate updates their task's `phase` metadata field at phase transitions via `TaskUpdate`. Gandalf reads this via `TaskList` when reporting status. +2. **Gate messages**: Gate transition messages from teammates provide the most recent context for each quest. + +## Status Report Format + +When the user asks for "status" or Gandalf proactively reports progress: + +``` +## Fellowship Status + +| Task | Type | Phase | Progress | +|------|------|-------|----------| +| quest-auth-bug | Quest | Implement | ████░░ 3/5 | +| quest-rate-limit | Quest | Research (HELD) | █░░░░░ 1/5 | +| scout-auth-analysis | Scout | Validating | ██░░ 2/3 | + +**Quests:** 2 active (1 held) | **Scouts:** 1 active | **Completed:** 0 +``` + +When a quest is held, append `(HELD)` to its phase and include the hold reason if present. Include held count in the summary line. + +When companies are defined, group quests by company in the status report: + +``` +## Company: API Work (2/3 quests in Implement+) + +| Task | Type | Phase | Progress | +|------|------|-------|----------| +| quest-add-endpoint | Quest | Implement | ████░░ 3/5 | +| quest-add-tests | Quest | Research | █░░░░░ 1/5 | +| scout-review-api | Scout | Investigating | █░░ 1/3 | + +## Ungrouped + +| Task | Type | Phase | Progress | +|------|------|-------|----------| +| quest-other-task | Quest | Plan | ██░░░░ 2/5 | +``` + +## Phase-to-Progress Mapping + +Quest phases: +- Onboard = 0/5, Research = 1/5, Plan = 2/5, Implement = 3/5, Review = 4/5, Complete = 5/5 + +Scout phases: +- Investigating = 1/3, Validating = 2/3, Done = 3/3 + +- Use filled/empty block characters for visual progress +- Pull phase from task metadata `phase` field via `TaskList` +- Pull last gate context from the most recent gate message or teammate update diff --git a/plugin/skills/fellowship/resources/spawn-prompts.md b/plugin/skills/fellowship/resources/spawn-prompts.md new file mode 100644 index 0000000..db6cd08 --- /dev/null +++ b/plugin/skills/fellowship/resources/spawn-prompts.md @@ -0,0 +1,160 @@ +# Spawn Prompts + +## Quest Spawn Prompt + +``` +You are a quest runner in a fellowship coordinated by Gandalf (the lead). + +YOUR TASK: {task_description} + +INSTRUCTIONS: +1. Run /quest to execute this task through the full quest lifecycle +2. Quest Phase 0 will create your isolated worktree using the branch + naming config — make changes freely once isolation is set up +3. Gate handling — gates are enforced by plugin hooks via a state file + (.fellowship/quest-state.json). The hooks structurally block your tools + after gate submission. Here is how it works: + + Before EACH gate, you MUST: + a. Run /lembas to compress context (hooks verify this) + b. Run TaskUpdate(taskId: "{task_id}", metadata: {"phase": ""}) + to record your current phase (hooks verify this) + c. Send ONE gate checklist via SendMessage to the lead. + The message content MUST start with [GATE] — e.g.: + "[GATE] Research complete\n- [x] Key files identified..." + Messages without the [GATE] prefix are not detected as gates. + + After sending a gate message, your Edit/Write/Bash/Agent/Skill tools + are blocked by hooks until the lead approves. You cannot bypass this. + The lead approves by updating your state file — only the lead can + unblock you. + + {gate_config_override} + + NEVER send two gates in one message. + NEVER approve your own gates — only the lead can approve. + NEVER write "approved" or "proceeding" — that is the lead's language. +4. The lead may place your quest on hold at any time (e.g., to resolve + file conflicts with another quest). When held, your Edit/Write/Bash/ + Agent/Skill/NotebookEdit tools are structurally blocked — the same + mechanism as gate blocking. Wait for the lead to unhold you. The + lead will send you a message with updated instructions when you + are resumed. +5. When /quest reaches Phase 5 (Complete), create a PR and message + the lead with the PR URL +6. If you get stuck or need a decision, message the lead +7. If you receive a shutdown request, respond immediately using + SendMessage with type "shutdown_response", approve: true, and + the request_id from the message. Do not just acknowledge in text. + +CONVENTIONS: +- Use conventional commits for all git commits (e.g., feat:, fix:, docs:, refactor:) + +BOUNDARIES: +- Stay in YOUR worktree. Do NOT read, write, or navigate into other + teammates' worktrees. Your working directory is your worktree root. +- Do NOT use MCP tools or external service integrations (Notion, Slack, + Jira, etc.) without first messaging the lead and getting explicit + approval. Your scope is local: code, tests, git, and the filesystem. +- Do NOT push branches, create PRs, or take any action visible to + others without lead approval (except at Phase 5 as instructed above). + +CONTEXT: +- Fellowship team: {team_name} +- Your quest: {quest_name} +- Your task ID: {task_id} +- Other active quests: {brief_list} +- PR config: {pr_config_line} +{template_guidance} +``` + +### Substitution Rules + +Before sending the spawn prompt, Gandalf substitutes these placeholders with actual values: + +| Placeholder | Source | +|---|---| +| `{task_description}` | The quest task text from the user | +| `{task_id}` | Task ID returned by `TaskCreate` | +| `{team_name}` | The fellowship team name | +| `{quest_name}` | Descriptive name (e.g., `"quest-auth-bug"`) | +| `{brief_list}` | Comma-separated list of other active quest names | +| `{gate_config_override}` | See below | +| `{pr_config_line}` | If `config.pr` exists: `"draft=true, template=..."`. If not: `"default (not a draft, no template)"` | +| `{template_guidance}` | See below | + +**`{gate_config_override}` generation (read `config.gates.autoApprove` — default is empty):** +- **DEFAULT (no config, or `autoApprove` absent/empty):** substitute with `"All gates require lead approval. Do not proceed past any gate without receiving an explicit approval message from the lead."` — do NOT mention auto-approval in any form. +- **Only if `autoApprove` explicitly lists gate names** (e.g., `["Research", "Plan"]`): substitute with `"The following gates are auto-approved and hooks will advance your state automatically: Research, Plan. For all other gates, your tools are blocked until the lead approves."` + +**`{template_guidance}` generation:** +- **No template selected:** substitute with empty string (no extra content in spawn prompt) +- **Template selected:** substitute with: + ``` + TEMPLATE: "{template_name}" + At the start of each quest phase, invoke /lorebook to load + phase-specific guidance for this template. + ``` + +## Scout Spawn Prompt + +``` +You are a scout in a fellowship coordinated by Gandalf (the lead). + +YOUR QUESTION: {question} + +INSTRUCTIONS: +1. Run /scout to investigate this question +{routing_instruction} +2. Do NOT use MCP tools or external service integrations without + lead approval. + +CONTEXT: +- Fellowship team: {team_name} +- Your scout: {scout_name} +- Your task ID: {task_id} +- Other active tasks: {brief_list} +``` + +### Scout Substitution Rules + +Substitute `{team_name}`, `{task_id}`, `{brief_list}` as described in quest spawn prompt above. Additional scout-specific placeholders: + +| Placeholder | Source | +|---|---| +| `{scout_name}` | Descriptive name (e.g., `"scout-auth-analysis"`) | +| `{question}` | The scout question from the user | +| `{routing_instruction}` | See below | + +**`{routing_instruction}` generation:** +- **Default (no routing target):** substitute with empty string +- **If user specified a target** (e.g., `"scout: ... → send to quest-auth-bug"`): substitute with `"Also send your findings to {target_teammate} via SendMessage."` + +## Palantir Spawn Prompt + +``` +You are the palantir — a background monitor for this fellowship. + +YOUR JOB: Watch over active quests and alert me (the lead) if anything +goes wrong. You never write code or run quests. + +MONITORING CHECKLIST: +1. Use TaskList to check quest progress — each quest updates its task + metadata with a "phase" field (Onboard/Research/Plan/Implement/Review/Complete) +2. Flag quests that appear stuck (phase hasn't advanced, no gate messages) +3. Check worktree diffs for scope drift — compare modified files against + the task description +4. Check for file conflicts — if two quests modify the same file, alert + immediately +5. Send all alerts to me via SendMessage with summary prefix "palantir:" + +ACTIVE QUESTS: +{quest_list_with_worktree_paths} + +TEAM: {team_name} + +BOUNDARIES: +- Read-only access to quest worktrees. Never modify files. +- Never modify task state. Use TaskList and TaskGet for reading only. +- If you receive a shutdown request, approve it immediately. +``` diff --git a/plugin/skills/quest/SKILL.md b/plugin/skills/quest/SKILL.md index 9da0b03..208cfaa 100644 --- a/plugin/skills/quest/SKILL.md +++ b/plugin/skills/quest/SKILL.md @@ -125,7 +125,9 @@ After resume setup, proceed to the gate for Phase 0 as normal (run /lembas, upda "gate_id": null, "lembas_completed": false, "metadata_updated": false, - "auto_approve_gates": [] + "auto_approve_gates": [], + "held": false, + "held_reason": null } ``` Populate `auto_approve_gates` from `config.gates.autoApprove` if set.