From 1944e165d4d97f7d2c238f52dd06659729f3eb3c Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Sun, 8 Mar 2026 11:20:19 -0500 Subject: [PATCH 1/5] feat: add structured conflict resolution with hold mechanism Add CLI hold/unhold commands that structurally block a quest's tools via the gate-guard hook when held. Gandalf uses this to pause quests during file conflict resolution detected by Palantir. Closes #14 Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 117 +++++++++++++++++++++++++++++- cli/internal/herald/herald.go | 2 + cli/internal/hooks/guard.go | 12 +++ cli/internal/hooks/guard_test.go | 35 +++++++++ cli/internal/state/state.go | 2 + plugin/skills/fellowship/SKILL.md | 50 +++++++++++++ plugin/skills/quest/SKILL.md | 4 +- 7 files changed, 220 insertions(+), 2 deletions(-) diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index cebc02c..e413f14 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 all tools + --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 @@ -306,6 +315,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 { @@ -390,6 +403,108 @@ 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") + s, err := state.Load(statePath) + if err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + if s.Held { + fmt.Fprintln(os.Stderr, "Quest is already held") + return 1 + } + + s.Held = true + if *reason != "" { + s.HeldReason = reason + } + if err := state.Save(statePath, s); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + questName := s.QuestName + 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: s.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") + s, err := state.Load(statePath) + if err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + if !s.Held { + fmt.Fprintln(os.Stderr, "Quest is not held") + return 1 + } + + s.Held = false + s.HeldReason = nil + if err := state.Save(statePath, s); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } + + questName := s.QuestName + if questName == "" { + questName = filepath.Base(*dir) + } + herald.Announce(*dir, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.QuestUnheld, + Phase: s.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/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..7e3867a 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -23,6 +23,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"} diff --git a/plugin/skills/fellowship/SKILL.md b/plugin/skills/fellowship/SKILL.md index c545bac..76e11e9 100644 --- a/plugin/skills/fellowship/SKILL.md +++ b/plugin/skills/fellowship/SKILL.md @@ -376,6 +376,56 @@ When Gandalf rejects a gate (or the user rejects): 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 +## 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 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. + ## Gandalf's Voice Gandalf speaks with the character of Gandalf the Grey — wise, occasionally wry, never flustered. Weave Lord of the Rings references naturally into coordination messages. Don't force it; let the situation prompt the reference. 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. From 7e8a6f01d2aa3bb31a7724d995373641412eb531 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Sun, 8 Mar 2026 11:31:43 -0500 Subject: [PATCH 2/5] refactor: decompose fellowship skill into main body + resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract spawn prompts, lead behavior, progress tracking, and conflict resolution into resources/ for progressive disclosure. Reduces SKILL.md from 613 to 197 lines — loaded into Gandalf's context at startup, resources loaded on-demand only when needed. Co-Authored-By: Claude Opus 4.6 --- plugin/skills/fellowship/SKILL.md | 506 ++---------------- .../resources/conflict-resolution.md | 49 ++ .../fellowship/resources/lead-behavior.md | 96 ++++ .../fellowship/resources/progress-tracking.md | 52 ++ .../fellowship/resources/spawn-prompts.md | 154 ++++++ 5 files changed, 396 insertions(+), 461 deletions(-) create mode 100644 plugin/skills/fellowship/resources/conflict-resolution.md create mode 100644 plugin/skills/fellowship/resources/lead-behavior.md create mode 100644 plugin/skills/fellowship/resources/progress-tracking.md create mode 100644 plugin/skills/fellowship/resources/spawn-prompts.md diff --git a/plugin/skills/fellowship/SKILL.md b/plugin/skills/fellowship/SKILL.md index 76e11e9..9ffbc89 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 - -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. +No built-in templates ship with fellowship. Use `/scribe` to create them. Parse YAML frontmatter for `name`, `description`, and `keywords`. -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. - -**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 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 + - Do NOT pass `isolation: "worktree"` — the teammate creates its own worktree during quest Phase 0. -**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} -``` +**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'`. -**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:** - -``` -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 | +1. `TaskCreate` with the question and type "scout" +2. Spawn via `Task` tool with `subagent_type: "fellowship:scout"`, no worktree isolation. -**`{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. +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. -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. - -### 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,104 +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. - -**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`. - -| 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 | +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. -**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. +**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`. -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. - -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"` +**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). ### Gate Approval Procedure -When Gandalf approves a non-auto-approved gate: - -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. +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 -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. - ### Gate Rejection Procedure -When Gandalf rejects a gate (or the user rejects): +1. **Clear pending:** `fellowship gate reject --dir ` +2. **Send rejection message** with feedback +3. Teammate addresses feedback, re-runs prerequisites, resubmits -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 +## Conflict Resolution -## Conflict Resolution Protocol +When Palantir raises a file conflict alert, Gandalf follows the conflict resolution protocol: Pause (hold) → Assess (real vs incidental) → Resolve (sequence/partition/merge) → Resume (unhold). -When Palantir raises a file conflict alert (two quests modifying the same file), Gandalf follows this protocol: +See [resources/conflict-resolution.md](resources/conflict-resolution.md) for the full protocol. -### 1. Pause +## Lead Behavior -Hold the later quest immediately to prevent further divergence: +Gandalf's decision tree and event handling rules — reactive (teammate events), proactive (user commands), gate tracking, and gate discipline. -```bash -fellowship hold --dir --reason "file conflict with : " -``` - -This structurally blocks the held quest's Edit/Write/Bash/Agent/Skill 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. +See [resources/lead-behavior.md](resources/lead-behavior.md) for the full behavior specification. -### 3. Resolve +## Progress Tracking -Pick one of three strategies based on the assessment: +Status report format, phase-to-progress mappings, and company grouping. -**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. +See [resources/progress-tracking.md](resources/progress-tracking.md) for details. ## Gandalf's Voice @@ -436,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..63b8e00 --- /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 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..d4d8276 --- /dev/null +++ b/plugin/skills/fellowship/resources/lead-behavior.md @@ -0,0 +1,96 @@ +# 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. +- **"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..c8d11f9 --- /dev/null +++ b/plugin/skills/fellowship/resources/progress-tracking.md @@ -0,0 +1,52 @@ +# 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 | █░░░░░ 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 | +``` + +## 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..cdc8506 --- /dev/null +++ b/plugin/skills/fellowship/resources/spawn-prompts.md @@ -0,0 +1,154 @@ +# 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. 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} +``` + +### 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. +``` From 7e1addb01d99be062b7c7011f74bf8220d441339 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Sun, 8 Mar 2026 11:37:48 -0500 Subject: [PATCH 3/5] fix: use file locking for state mutations in gate/hold/unhold commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add state.WithLock for atomic load→mutate→save on quest-state.json, matching the WithStateLock pattern used for fellowship-state.json. Migrate gate approve, gate reject, hold, and unhold to use it. Also add NotebookEdit to the list of blocked tools in conflict resolution docs. Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 133 +++++++++--------- cli/internal/state/state.go | 29 ++++ .../resources/conflict-resolution.md | 2 +- 3 files changed, 95 insertions(+), 69 deletions(-) diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index e413f14..0682f11 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -327,22 +327,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 } @@ -352,46 +355,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 @@ -415,27 +420,23 @@ func runHold(args []string) int { } statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") - s, err := state.Load(statePath) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - if s.Held { - fmt.Fprintln(os.Stderr, "Quest is already held") - return 1 - } - - s.Held = true - if *reason != "" { - s.HeldReason = reason - } - if err := state.Save(statePath, s); err != nil { + 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 } - questName := s.QuestName if questName == "" { questName = filepath.Base(*dir) } @@ -447,7 +448,7 @@ func runHold(args []string) int { Timestamp: time.Now().UTC().Format(time.RFC3339), Quest: questName, Type: herald.QuestHeld, - Phase: s.Phase, + Phase: phase, Detail: detail, }) @@ -471,25 +472,21 @@ func runUnhold(args []string) int { } statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") - s, err := state.Load(statePath) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - if !s.Held { - fmt.Fprintln(os.Stderr, "Quest is not held") - return 1 - } - - s.Held = false - s.HeldReason = nil - if err := state.Save(statePath, s); err != nil { + 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 } - questName := s.QuestName if questName == "" { questName = filepath.Base(*dir) } @@ -497,7 +494,7 @@ func runUnhold(args []string) int { Timestamp: time.Now().UTC().Format(time.RFC3339), Quest: questName, Type: herald.QuestUnheld, - Phase: s.Phase, + Phase: phase, Detail: "Quest unheld — resumed", }) diff --git a/cli/internal/state/state.go b/cli/internal/state/state.go index 7e3867a..a485678 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "github.com/justinjdev/fellowship/cli/internal/datadir" ) @@ -77,6 +78,34 @@ 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 := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + return fmt.Errorf("acquiring lock: %w", err) + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + + s, err := Load(path) + if err != nil { + return err + } + + if err := fn(s); err != 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/resources/conflict-resolution.md b/plugin/skills/fellowship/resources/conflict-resolution.md index 63b8e00..c90cdae 100644 --- a/plugin/skills/fellowship/resources/conflict-resolution.md +++ b/plugin/skills/fellowship/resources/conflict-resolution.md @@ -10,7 +10,7 @@ Hold the later quest immediately to prevent further divergence: fellowship hold --dir --reason "file conflict with : " ``` -This structurally blocks the held quest's Edit/Write/Bash/Agent/Skill tools via the gate-guard hook. The quest cannot proceed until unheld. +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. From b156f7dc2b84ed2befab41ef1d3ef30f05e3d27a Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Sun, 8 Mar 2026 11:50:35 -0500 Subject: [PATCH 4/5] fix: address PR review feedback - Add file locking to hook mutations (gate-submit, gate-prereq, metadata-track) via state.WithLock with ErrNoSave sentinel - Add hold/unhold as proactive commands in lead-behavior docs - Surface held quests in progress tracking status format - Explain hold mechanism in quest runner spawn prompt - Name concrete hold/unhold commands in SKILL.md conflict section - Fix usage text: list specific blocked tools instead of "all tools" Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 148 +++++++++++------- cli/internal/state/state.go | 7 + plugin/skills/fellowship/SKILL.md | 2 +- .../fellowship/resources/lead-behavior.md | 2 + .../fellowship/resources/progress-tracking.md | 6 +- .../fellowship/resources/spawn-prompts.md | 12 +- 6 files changed, 112 insertions(+), 65 deletions(-) diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index 0682f11..5d2d449 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -104,7 +104,7 @@ Agent/lead commands: 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 all tools + 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 @@ -203,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 { @@ -221,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 { diff --git a/cli/internal/state/state.go b/cli/internal/state/state.go index a485678..864f103 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -13,6 +13,10 @@ import ( "github.com/justinjdev/fellowship/cli/internal/datadir" ) +// 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"` @@ -100,6 +104,9 @@ func WithLock(path string, fn func(s *State) error) error { } if err := fn(s); err != nil { + if err == ErrNoSave { + return nil + } return err } diff --git a/plugin/skills/fellowship/SKILL.md b/plugin/skills/fellowship/SKILL.md index 9ffbc89..cfb31a7 100644 --- a/plugin/skills/fellowship/SKILL.md +++ b/plugin/skills/fellowship/SKILL.md @@ -144,7 +144,7 @@ Each quest runs the full `/quest` lifecycle (6 phases with gates). Gates are enf ## Conflict Resolution -When Palantir raises a file conflict alert, Gandalf follows the conflict resolution protocol: Pause (hold) → Assess (real vs incidental) → Resolve (sequence/partition/merge) → Resume (unhold). +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 `). See [resources/conflict-resolution.md](resources/conflict-resolution.md) for the full protocol. diff --git a/plugin/skills/fellowship/resources/lead-behavior.md b/plugin/skills/fellowship/resources/lead-behavior.md index d4d8276..6bd59c9 100644 --- a/plugin/skills/fellowship/resources/lead-behavior.md +++ b/plugin/skills/fellowship/resources/lead-behavior.md @@ -79,6 +79,8 @@ This is defense-in-depth — the `completion-guard` hook also mechanically block - **"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` diff --git a/plugin/skills/fellowship/resources/progress-tracking.md b/plugin/skills/fellowship/resources/progress-tracking.md index c8d11f9..c45356e 100644 --- a/plugin/skills/fellowship/resources/progress-tracking.md +++ b/plugin/skills/fellowship/resources/progress-tracking.md @@ -15,12 +15,14 @@ When the user asks for "status" or Gandalf proactively reports progress: | Task | Type | Phase | Progress | |------|------|-------|----------| | quest-auth-bug | Quest | Implement | ████░░ 3/5 | -| quest-rate-limit | Quest | Research | █░░░░░ 1/5 | +| quest-rate-limit | Quest | Research (HELD) | █░░░░░ 1/5 | | scout-auth-analysis | Scout | Validating | ██░░ 2/3 | -**Quests:** 2 active | **Scouts:** 1 active | **Completed:** 0 +**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: ``` diff --git a/plugin/skills/fellowship/resources/spawn-prompts.md b/plugin/skills/fellowship/resources/spawn-prompts.md index cdc8506..db6cd08 100644 --- a/plugin/skills/fellowship/resources/spawn-prompts.md +++ b/plugin/skills/fellowship/resources/spawn-prompts.md @@ -34,10 +34,16 @@ INSTRUCTIONS: 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 +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 -5. If you get stuck or need a decision, message the lead -6. If you receive a shutdown request, respond immediately using +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. From 1c6285fe9c5c08f7f7672c19000e966d205f3ae0 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Sun, 8 Mar 2026 11:52:48 -0500 Subject: [PATCH 5/5] fix: use cross-platform file locking instead of syscall.Flock Extract file locking into internal/filelock package with build tags: unix uses syscall.Flock, windows uses LockFileEx/UnlockFileEx via kernel32.dll. Fixes compilation on Windows. Co-Authored-By: Claude Opus 4.6 --- cli/internal/dashboard/fellowship.go | 6 +-- cli/internal/filelock/filelock_unix.go | 15 +++++++ cli/internal/filelock/filelock_windows.go | 50 +++++++++++++++++++++++ cli/internal/state/state.go | 6 +-- 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 cli/internal/filelock/filelock_unix.go create mode 100644 cli/internal/filelock/filelock_windows.go 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/state/state.go b/cli/internal/state/state.go index 864f103..e85656b 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -8,9 +8,9 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "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 @@ -93,10 +93,10 @@ func WithLock(path string, fn func(s *State) 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 := Load(path) if err != nil {