diff --git a/.genie/brainstorms/agent-directory/DESIGN.md b/.genie/brainstorms/agent-directory/DESIGN.md new file mode 100644 index 000000000..07f86d585 --- /dev/null +++ b/.genie/brainstorms/agent-directory/DESIGN.md @@ -0,0 +1,310 @@ +# DESIGN: Agent Directory & Multi-Agent Spawn Redesign + +> Redesign genie's agent spawn and communication system so that pre-existing agents +> (each with their own home directory, identity files, and project assignments) can be +> registered once, spawned in one command, and message each other by name. + +## Problem + +`genie agent spawn` creates clones of the spawning agent — they inherit the parent's session context and CLAUDE.md rather than loading their own AGENTS.md identity. Cross-agent messaging requires manual `--team` flags. CWD and identity source are conflated via the `-d` flag. This blocks multi-agent workflows where a PM, Engineer, and QA need independent identities while collaborating on the same project. + +## Architecture + +### Two Registries, Two Lifecycles + +``` +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ Agent Directory │ │ Worker Registry │ +│ ~/.genie/agent-directory │ │ ~/.genie/workers.json │ +│ .json │ │ │ +│ │ │ │ +│ WHO the agent IS │ │ HOW to restart it │ +│ - name │ │ - paneId, session │ +│ - home (AGENTS.md src) │ │ - claudeSessionId │ +│ - project (CWD) │ │ - state, team │ +│ - default team │ │ + templates[] for recovery │ +│ │ │ │ +│ Human-configured │ │ Auto-generated at spawn │ +│ Persistent across reboots │ │ Ephemeral (pane lifecycle) │ +│ Source of truth for ID │ │ Source of truth for state │ +└──────────────┬──────────────┘ └──────────────┬──────────────┘ + │ │ + │ 1. spawn resolves identity │ 3. template saved + │ 2. CWD + prompt injected │ for auto-respawn + ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ genie agent spawn │ + │ Reads directory → injects identity → │ + │ launches in tmux → registers worker → │ + │ saves template for recovery │ + └─────────────────────────────────────────────┘ +``` + +### Key Separations + +| Concern | Source | Used At | +|---------|--------|---------| +| **Identity** (AGENTS.md) | `--home` directory in agent-directory.json | Spawn time → `--append-system-prompt` | +| **Workspace** (CWD) | `--project` path in agent-directory.json | Spawn time → tmux pane CWD | +| **Address** | Agent name in directory | Message routing → flat, no `--team` prefix | +| **Team** | Optional grouping | Tmux window organization, not required for identity or messaging | +| **Recovery** | WorkerTemplate in workers.json | Auto-respawn on message to dead agent | + +## Scope + +### IN +- Persistent agent directory (`~/.genie/agent-directory.json`) +- `genie agent register --home --project ` subcommand +- `genie agent unregister ` subcommand +- `genie agent directory` subcommand (list all registered agents with status) +- `genie agent spawn ` resolving from directory (positional arg) +- Identity injection: read AGENTS.md from `--home`, inject via `--append-system-prompt` +- CWD/identity separation: pane opens at `--project`, identity from `--home` +- Flat messaging: `genie send --to ` resolves via directory without `--team` +- Auto-spawn on message to offline registered agent +- Backward compat: `genie agent spawn --role implementor` still works unchanged + +### OUT +- Changes to Claude Code's native teammate protocol itself +- New messaging transport (still mailbox + native inbox) +- Multi-project per agent (one project at a time per directory entry) +- Modifying AGENTS.md content (directory is a pointer, not an editor) + +## Detailed Design + +### 1. New Module: `src/lib/agent-directory.ts` + +Persistent JSON registry at `~/.genie/agent-directory.json`. + +```typescript +// Schema +interface DirectoryEntry { + name: string; // unique key, e.g., "totvs-pm" + home: string; // absolute path to agent home (contains AGENTS.md) + project: string; // absolute path to project repo (CWD at spawn) + team?: string; // optional default team grouping + registeredAt: string; // ISO timestamp +} + +interface AgentDirectory { + entries: Record; + lastUpdated: string; +} + +// Public API +register(name, home, project, team?): void // persist entry +unregister(name): void // remove entry +resolve(name): DirectoryEntry | null // lookup by name +list(): DirectoryEntry[] // all entries +loadIdentity(name): string | null // read AGENTS.md from home +``` + +**Storage:** Same `~/.genie/` directory as `workers.json` and `config.json`. Uses the same file-lock pattern from `agent-registry.ts` for concurrent access safety. + +**Path validation:** `resolve()` returns the entry as-is (fast). `loadIdentity()` checks if `home/AGENTS.md` exists and returns null if missing. Spawn command fails fast with clear error on missing paths. + +### 2. Modified: `src/lib/provider-adapters.ts` + +Add `systemPrompt?: string` to `SpawnParams`: + +```typescript +export interface SpawnParams { + // ... existing fields ... + /** System prompt content to inject via --append-system-prompt. */ + systemPrompt?: string; +} +``` + +In `buildClaudeCommand()`: if `systemPrompt` is provided, persist to file via the existing `persistSystemPrompt()` pattern from `team-lead-command.ts`, then add `--append-system-prompt "$(cat )"`. + +**Implementation detail:** Extract `persistSystemPrompt()` from `team-lead-command.ts` into a shared utility (or just import it). The file goes to `~/.genie/prompts/.md`. The existing `promptMode` config (`'append'` vs `'system'`) is respected. + +### 3. Modified: `src/term-commands/agents.ts` — spawn command + +Change the spawn command signature from: + +``` +genie agent spawn --role [--team ] [--cwd ] ... +``` + +To: + +``` +genie agent spawn [name] --role [--team ] [--cwd ] ... +``` + +Resolution logic in `handleWorkerSpawn()`: + +``` +1. If positional `name` provided: + a. Resolve from agent directory + b. If not found → error: "Agent '' not registered. Run: genie agent register ..." + c. Set CWD = entry.project (override --cwd if not explicitly provided) + d. Load AGENTS.md from entry.home → set systemPrompt on SpawnParams + e. Set GENIE_AGENT_NAME = name (via env in launch command) + f. Use entry.team as default team (if --team not explicit) + g. Use name as the role for native team registration + +2. If no positional name (--role provided): + a. Existing behavior, unchanged + b. No directory lookup +``` + +**Backward compat:** `--role` becomes optional (was `.requiredOption`). Validation: either positional `name` or `--role` must be provided, error otherwise. + +### 4. New Subcommands in `agents.ts` + +``` +genie agent register --home --project [--team ] +genie agent unregister +genie agent directory [--json] +``` + +**register:** Validates both paths exist on disk. Writes to `agent-directory.json`. + +**unregister:** Removes entry. Does not affect running workers or templates. + +**directory:** Lists all registered agents with runtime state enrichment: + +``` +NAME HOME PROJECT STATUS TEAM +totvs-pm ~/.../totvs-pm ~/.../projects/totvs-poc idle recon +totvs-engineer ~/.../totvs-recon-engineer ~/.../projects/totvs-poc working recon +totvs-qa ~/.../totvs-qa ~/.../projects/totvs-poc stopped — +``` + +STATUS is derived by cross-referencing the worker registry: if a worker with matching name/role exists and its pane is alive → show its state. Otherwise → "stopped". + +### 5. Modified: `src/lib/protocol-router.ts` — sendMessage() + +Add directory-aware resolution as the **first** lookup tier in `sendMessage()`: + +``` +Current: resolveRecipient(to) → workers by ID > role > team:role +New: directoryResolve(to) → agent directory by name + ↓ (if found + alive) + deliver directly + ↓ (if found + not alive) + auto-spawn from directory entry → deliver + ↓ (if not found in directory) + resolveRecipient(to) → existing worker registry resolution +``` + +**Auto-spawn from directory:** When a directory agent isn't running, the router: +1. Calls `agentDirectory.resolve(to)` → gets home + project +2. Calls `agentDirectory.loadIdentity(to)` → gets AGENTS.md content +3. Spawns using the same `spawnWorkerFromTemplate` pattern but with directory-derived params +4. Waits for ready, delivers message + +**Collision handling:** If an agent name in the directory matches a worker ID from a different context, directory wins. A debug-level warning is logged. + +### 6. Modified: `src/hooks/handlers/auto-spawn.ts` + +The hook also needs directory awareness for the case where Claude Code's SendMessage fires before the protocol router handles it: + +``` +Current: check worker registry → check templates → spawn from template +New: check worker registry → check agent directory → check templates +``` + +If the directory has the recipient, spawn using directory identity (same as protocol-router path). This keeps the hook and router consistent. + +### 7. Identity Injection Flow (end-to-end) + +``` +1. Human runs: genie agent register totvs-pm \ + --home /home/genie/agents/namastexlabs/totvs-pm \ + --project /home/genie/agents/namastexlabs/projects/totvs-poc + +2. Human (or another agent) runs: genie agent spawn totvs-pm + +3. handleWorkerSpawn(): + a. directory.resolve("totvs-pm") → { home: "/...totvs-pm", project: "/...totvs-poc" } + b. directory.loadIdentity("totvs-pm") → reads /...totvs-pm/AGENTS.md → string content + c. persistSystemPrompt("totvs-pm", content) → writes ~/.genie/prompts/totvs-pm.md + d. SpawnParams.systemPrompt = content + e. buildClaudeCommand() adds: --append-system-prompt "$(cat ~/.genie/prompts/totvs-pm.md)" + f. Launch env includes: GENIE_AGENT_NAME=totvs-pm + g. tmux pane CWD = /...totvs-poc + +4. Agent starts with: + - Its own AGENTS.md as system prompt (independent identity) + - CWD in the project repo (not its home directory) + - GENIE_AGENT_NAME set (identity-inject hook tags outgoing messages) + +5. PM sends to engineer: genie send 'assess filter extension' --to totvs-engineer + a. protocol-router.sendMessage() → directoryResolve("totvs-engineer") + b. If alive → deliver + c. If not alive → auto-spawn from directory → deliver + d. No --team flag needed +``` + +## Risks + +| ID | Risk | Severity | Mitigation | +|----|------|----------|------------| +| R1 | **Dual resolution ambiguity** — directory name collides with worker ID/role from different team | Medium | Directory wins on exact name match. Log warning on collision. | +| R2 | **Stale directory entries** — agent home moved/deleted, spawn fails | Low | `loadIdentity()` returns null on missing AGENTS.md. Spawn fails fast with clear error. `genie agent directory` validates paths and shows warnings. | +| R3 | **`--append-system-prompt` size limits** — large AGENTS.md files | Low | Reuse `persistSystemPrompt()` file-persist + `$(cat)` pattern from `team-lead-command.ts`. Already battle-tested. | +| R4 | **Auto-spawn race** — hook and router both try to spawn | Medium | Both paths check `isPaneAlive()` before spawning. `cleanupDeadWorkers()` runs before spawn. Same guards that prevent double-spawn today. | +| R5 | **`--role` required → optional** — could break scripts | Low | Validation: require either positional `name` OR `--role`. Error message guides users. Existing `--role` invocations are unaffected. | + +## Acceptance Criteria + +1. **Register + resolve:** `genie agent register totvs-pm --home /path --project /path` persists to `~/.genie/agent-directory.json`. `genie agent directory` lists it. +2. **Spawn by name:** `genie agent spawn totvs-pm` sets CWD to project, injects AGENTS.md from home via `--append-system-prompt`, sets `GENIE_AGENT_NAME=totvs-pm`. +3. **Independent identity:** Two directory agents spawned into the same project have different AGENTS.md injected. Verified by differing `~/.genie/prompts/.md`. +4. **Flat messaging:** `genie send 'hello' --to totvs-engineer` delivers without `--team`, resolving via directory. +5. **Auto-spawn + deliver:** Sending to an offline directory agent triggers spawn then delivery. +6. **Template auto-creation:** Spawning a directory agent saves a `WorkerTemplate` for crash recovery. +7. **Backward compat:** `genie agent spawn --role implementor --team myteam` works unchanged. +8. **Path validation:** Spawn fails fast with clear error if home or project path doesn't exist. +9. **Unregister:** `genie agent unregister totvs-pm` removes from directory without affecting running workers. + +## Implementation Groups + +### Group 1: Foundation (no behavioral changes) +- [ ] Create `src/lib/agent-directory.ts` with register/unregister/resolve/list/loadIdentity +- [ ] Extract `persistSystemPrompt()` from `team-lead-command.ts` into shared util +- [ ] Add `systemPrompt?: string` to `SpawnParams` in `provider-adapters.ts` +- [ ] Wire `systemPrompt` into `buildClaudeCommand()` using persist+cat pattern + +### Group 2: Spawn Integration +- [ ] Add `register`, `unregister`, `directory` subcommands to `agents.ts` +- [ ] Add optional `[name]` positional to `spawn` command +- [ ] Make `--role` conditionally required (required if no positional name) +- [ ] Wire directory resolution into `handleWorkerSpawn()` +- [ ] Set `GENIE_AGENT_NAME` and CWD from directory entry at spawn + +### Group 3: Messaging Integration +- [ ] Add directory-aware first-pass resolution in `protocol-router.ts:sendMessage()` +- [ ] Add directory lookup in `auto-spawn.ts` hook (before template fallback) +- [ ] Implement auto-spawn-from-directory in protocol router for offline agents +- [ ] Verify `identity-inject.ts` works with directory-spawned agents (uses GENIE_AGENT_NAME — should work as-is) + +### Group 4: Polish + Validation +- [ ] `genie agent directory` shows runtime state by cross-referencing worker registry +- [ ] Path validation warnings in directory listing +- [ ] Tests for agent-directory.ts (register, resolve, loadIdentity) +- [ ] Tests for directory-aware spawn (mock directory, verify systemPrompt injection) +- [ ] Integration test: register → spawn → send message → verify delivery + +## Files Changed + +| File | Change Type | Description | +|------|------------|-------------| +| `src/lib/agent-directory.ts` | **New** | Persistent agent directory module | +| `src/lib/provider-adapters.ts` | Modified | Add `systemPrompt` to SpawnParams, wire into buildClaudeCommand | +| `src/lib/team-lead-command.ts` | Modified | Extract `persistSystemPrompt` to shared location | +| `src/term-commands/agents.ts` | Modified | Add register/unregister/directory subcommands, optional positional spawn | +| `src/lib/protocol-router.ts` | Modified | Directory-first resolution in sendMessage | +| `src/hooks/handlers/auto-spawn.ts` | Modified | Directory lookup before template fallback | +| `src/types/genie-config.ts` | Unchanged | No config schema changes needed | + +## Non-Goals (Explicit) + +- No changes to Claude Code's native teammate protocol +- No new messaging transport +- No multi-project support per agent +- No auto-discovery of agent directories (explicit registration only) +- No modifications to AGENTS.md content from genie diff --git a/.genie/brainstorms/agent-directory/DRAFT.md b/.genie/brainstorms/agent-directory/DRAFT.md new file mode 100644 index 000000000..bd35763c1 --- /dev/null +++ b/.genie/brainstorms/agent-directory/DRAFT.md @@ -0,0 +1,628 @@ +# Genie CLI v2 — Complete Framework Redesign + +**Status:** Ready +**WRS:** 100/100 + +## Problem +The genie CLI has accumulated 40+ commands with overlapping functionality, inconsistent naming, and poor DX. Agent spawning creates clones instead of independent agents. Cross-agent messaging requires manual `--team` flags. Task state tracking (beads) is heavyweight and unreliable. Skill prompts don't align with the orchestration model. The entire framework needs a clean-break redesign. + +--- + +## Architecture Decisions (all confirmed) + +### 1. Native `--system-prompt-file` / `--append-system-prompt-file` +Claude Code hidden flags, confirmed working. Eliminates `persistSystemPrompt()`, `$(cat)` pattern, all temp files. Genie passes the file path directly to Claude Code at spawn time. + +### 2. One Folder Per Agent +Each agent has ONE folder. That folder is where Claude Code starts (CWD) and contains `AGENTS.md` (identity). May or may not have git — irrelevant to agent identity. + +### 3. Repo is Team-Level +- Agent directory entries CAN have an optional `repo` for solo use +- When agent is in a team, the team's `repo` overrides the agent's individual repo +- All team members work in the same repo/worktree +- Hierarchy: team repo > agent repo > agent dir (fallback CWD) + +### 4. Per-Agent Prompt Mode +Directory entry stores `system` or `append`: +- **PMs, non-coders:** `system` — replace Claude's default coding prompt entirely +- **Engineers:** `append` — keep Claude's coding capabilities + add agent identity +- Determines whether `--system-prompt-file` or `--append-system-prompt-file` is used at spawn + +### 5. Per-Agent Model Default +Directory entry stores optional `model` (e.g., `sonnet`, `opus`, `codex`). Can be overridden at spawn time with `--model`. + +### 6. Optional Roles Declaration +Directory entry stores optional `roles[]` — built-in roles the agent can orchestrate (e.g., `implementor`, `tester`, `debugger`). Helps the agent self-organize without PM spelling it out. Dynamic orchestration still takes priority — having roles declared doesn't force anything. + +### 7. No Backward Compatibility +Clean break. All old patterns replaced. + +### 8. Agent Names Globally Unique +Flat routing by name. No `--team` needed for messaging. + +### 9. Agents Can Be in Multiple Teams +Same agent hired into different teams, working on different tasks simultaneously. + +### 10. Send Scoped to Own Team +Team leader can only message members of their own team (prevents cross-talk). + +--- + +## Agents vs Roles + +**Agents** and **Roles** are separate concepts: + +- **Agents** = registered entities with identity (totvs-engineer, totvs-pm). Persistent. Have AGENTS.md, memory, personality. Registered in user directory. +- **Roles** = built-in capabilities (implementor, tester, reviewer, debugger...). Ephemeral. Spawned on demand. No identity, no memory. Ship with genie package. +- **Orchestration** = who bosses whom. Decided dynamically at runtime by whoever is leading. Not statically configured. + +### Hierarchy Examples + +**Complex project (dedicated agents):** +``` +totvs-pm (agent) + └─ totvs-engineer (agent, roles: [implementor, tester, debugger]) + └─ spawns implementor/tester/debugger roles as needed + └─ totvs-qa (agent, roles: [reviewer, verifier, security]) + └─ spawns reviewer/verifier roles as needed +``` + +**Simple project (no dedicated agents):** +``` +PM (agent) + └─ implementor (built-in role, spawned directly) + └─ reviewer (built-in role, spawned directly) +``` + +### Built-in Roles (ship with genie) + +| Role | Description | +|---|---| +| `implementor` | Implements features and fixes bugs | +| `tester` | Writes and runs tests | +| `reviewer` | Reviews code and provides feedback | +| `debugger` | Diagnoses and fixes bugs | +| `verifier` | Verifies fixes and writes regression tests | +| `investigator` | Investigates root causes | +| `reproducer` | Creates minimal reproductions | +| `dreamer` | Generates ideas and explores possibilities | +| `critic` | Evaluates and refines ideas | +| `security` | Security-focused review | + +### Built-in Council Members (ship with genie) + +| Member | Lens | Default Model | +|---|---|---| +| `council-questioner` | Challenge assumptions | sonnet | +| `council-benchmarker` | Performance evidence | sonnet | +| `council-simplifier` | Complexity reduction | sonnet | +| `council-sentinel` | Security oversight | opus | +| `council-ergonomist` | Developer experience | sonnet | +| `council-architect` | Systems thinking | opus | +| `council-operator` | Operations reality | sonnet | +| `council-deployer` | Zero-config deployment | sonnet | +| `council-measurer` | Observability | sonnet | +| `council-tracer` | Production debugging | sonnet | + +Council is hired as a group: `genie team hire council` (all or none). + +### Resolution Order +``` +User directory (genie dir add) > Built-in agents (ships with package) +``` +User can override a built-in by registering the same name. + +--- + +## Session & Team Flow + +### Default Session (no args) +```bash +genie +# Opens persistent session in current dir +# For quick questions, ongoing conversation +# No team, no worktree +``` + +### Named Session +```bash +genie --session +# Start or resume a named leader session +# Claude Code session naming used by default (not UUIDs) +``` + +### Team-Based Work +```bash +genie team create fix/auth-bug --repo ~/repos/genie --branch dev +# 1. Reads worktreeBase from ~/.genie/config.json (default: '.worktrees') +# 2. git -C ~/repos/genie pull origin dev +# 3. git -C ~/repos/genie worktree add /fix/auth-bug -b fix/auth-bug dev +# 4. Team leader session starts in /fix/auth-bug/ +# 5. All hired agents work in /fix/auth-bug/ +``` + +### Team Name = Branch Name +Following conventional git prefixes. No separate `--prefix` flag: +```bash +genie team create feat/agent-directory --repo ~/repos/genie --branch dev +genie team create fix/auth-bug --repo ~/repos/genie --branch dev +genie team create chore/cleanup --repo ~/repos/genie --branch dev +``` + +--- + +## Shared Worktree as Context Layer + +All context files live in the team's worktree `.genie/` folder. No commits needed for sharing — all agents read/write from the same filesystem. + +``` +/fix/auth-bug/ +├── .genie/ +│ ├── brainstorms/auth-bug/DRAFT.md # shared brainstorm draft +│ ├── brainstorms/auth-bug/DESIGN.md # crystallized design +│ ├── wishes/auth-bug.md # shared wish +│ └── state/auth-bug.json # task state (genie-managed) +├── src/ +└── ... +``` + +--- + +## State Machine (replaces beads) + +**Core principle: agents never touch the state file. Genie is the state machine.** + +### State File: `.genie/state/.json` + +```json +{ + "wish": "auth-bug", + "groups": { + "1": { + "status": "done", + "assignee": "totvs-engineer", + "startedAt": "2026-03-13T14:00:00Z", + "completedAt": "2026-03-13T14:30:00Z" + }, + "2": { + "status": "in_progress", + "assignee": "totvs-engineer", + "startedAt": "2026-03-13T14:35:00Z" + }, + "3": { + "status": "blocked", + "dependsOn": [2] + } + } +} +``` + +### State Transition Rules +1. Only `genie work` sets `in_progress` (at dispatch time, BEFORE spawn) +2. Only `genie done` sets `done` (explicit signal, run by leader or agent) +3. Only genie reads the state file to enforce ordering +4. Hooks validate: can't dispatch group N+1 until group N is `done` +5. Agents never import, read, or write the state file directly +6. Orchestrator (team leader) keeps track of guarantees at prompt level + +### Failure Modes +| Failure | What Happens | +|---|---| +| Agent crashes mid-work | Group stays `in_progress`. Leader sees it, re-dispatches | +| Agent finishes but nobody runs `done` | Group stays `in_progress`. Leader follows up | +| Someone tries to start group 3 early | Genie refuses: "group 2 is not done" | +| Leader forgets where they left off | `genie status ` shows all group states | + +--- + +## Dispatch Commands (lifecycle) + +Four dispatch commands mirror the skill lifecycle. Each one: resolves context → manages state → spawns agent with rich context injection. + +### Context Injection Pattern +All dispatch commands inject: +1. **File path** to the full document (wish, brainstorm, etc.) — agent can read the whole thing +2. **Extracted section** content — the specific group/section being worked on +3. **Wish-level context** — problem statement, scope, decisions (the WHY) + +### `genie brainstorm ` +- Reads `.genie/brainstorms//DRAFT.md` from shared worktree +- Spawns agent with draft content + file path as context +- Agent enters `/brainstorm` with full seed +- **Multi-agent:** multiple agents can brainstorm the same topic. PM kicks off, delegates, reviews +- Human can participate at any time via messaging + +### `genie wish ` +- Reads `.genie/brainstorms//DESIGN.md` from shared worktree +- Spawns agent with design as context + file path +- Agent enters `/wish` with crystallized design +- **Collaborative:** PM and agent go back-and-forth on wish quality +- Creates state file with group definitions and dependency graph + +### `genie work #` +- Reads `.genie/wishes/.md` — passes file path for full context +- Extracts specific group content (tasks, acceptance criteria) +- Checks state: are dependencies met? → refuses if not +- Sets group to `in_progress` in state file BEFORE spawn +- Spawns agent with group context + wish file path +- Agent enters `/work` ready to execute + +### `genie review #` +- Reads wish group + PR/diff context +- Spawns agent with review scope + file path +- Agent enters `/review` with criteria to validate against +- Council can participate (via team chat) + +### `genie done #` +- Sets group to `done` in state file +- Unblocks dependent groups + +### `genie status ` +- Shows all groups with current state, assignees, timestamps + +--- + +## Command Tree v2 (complete) + +### Entry Point +``` +genie # Persistent session in current dir +genie --session # Named/resumed leader session +``` + +### Dispatch (lifecycle — team leader orchestration) +``` +genie brainstorm # Spawn + inject brainstorm context +genie wish # Spawn + inject design for wish creation +genie work # # Check deps → in_progress → spawn with context +genie review # # Spawn + inject review scope +genie done # # Mark group done, unblock dependents +genie status # Show wish group states +``` + +### Agent Lifecycle (top-level verbs) +``` +genie spawn # Spawn registered agent or built-in role +genie kill # Force kill agent +genie stop # Stop current run, keep pane alive +genie ls # List agents, teams, state +genie history # Compressed session timeline +genie read # Tail agent pane output +genie answer # Answer agent prompt (menu nav / text) +``` + +### Messaging (flat routing by name) +``` +genie send '' --to # Direct message (scoped to own team) +genie broadcast '' # Leader → all team members (one-way) +genie chat '' [--team ] # Team group channel (interactive) +genie chat read [--team ] # Read team channel history +genie inbox [] [--unread] # View inbox +``` + +### Directory (agent registry) +``` +genie dir add # Add agent to directory + --dir # Agent folder (CWD + AGENTS.md) + [--repo ] # Default git repo (overridden by team) + [--prompt-mode append|system] # Default: append + [--model ] # Default model + [--roles ] # Built-in roles this agent can orchestrate +genie dir rm # Remove from directory +genie dir ls [] # List all or show single entry +genie dir edit # Update entry fields + [--dir ] + [--repo ] + [--prompt-mode append|system] + [--model ] + [--roles ] +``` + +### Team (dynamic collaboration) +``` +genie team create # Form team + worktree (idempotent) + --repo # Git repo (required) + [--branch ] # Base branch (default: dev) +genie team hire # Add agent (auto-detects team from leader) + [--team ] +genie team hire council # Hire all 10 council members +genie team fire # Remove agent + [--team ] +genie team ls [] # List teams or team members +genie team disband # Kill members, cleanup worktree +``` + +### Infrastructure +``` +genie setup # Install (review with install.sh base) +genie doctor # Diagnostics (review dep coverage) +genie shortcuts show|install|uninstall # tmux keyboard shortcuts +``` + +--- + +## Complete Command Fate Map + +### PROMOTED TO TOP-LEVEL +| Old | New | +|---|---| +| `genie agent spawn --role` | `genie spawn ` | +| `genie agent list` | `genie ls` | +| `genie agent kill ` | `genie kill ` | +| `genie agent suspend ` | `genie stop ` | +| `genie agent history ` | `genie history ` | +| `genie agent answer ` | `genie answer ` | +| `genie agent read ` | `genie read ` | +| `genie agent close + ship` | `genie done #` | +| `genie send` | `genie send` (flat routing) | +| `genie inbox` | `genie inbox` | + +### NEW COMMANDS +| Command | Purpose | +|---|---| +| `genie --session ` | Named leader sessions | +| `genie brainstorm ` | Multi-agent brainstorm dispatch | +| `genie wish ` | Collaborative wish creation dispatch | +| `genie work #` | State-managed work dispatch | +| `genie review #` | Review dispatch | +| `genie done #` | Explicit completion signal | +| `genie status ` | Wish state overview | +| `genie broadcast ''` | Leader → all members | +| `genie chat` / `genie chat read` | Team group channel | +| `genie team hire/fire` | Dynamic membership | +| `genie team hire council` | Group hire all council members | +| `genie dir add/rm/ls/edit` | Agent directory CRUD | + +### DROPPED +| Command | Reason | +|---|---| +| `genie agent dashboard` | Never used | +| `genie agent watchdog` | Never used | +| `genie agent approve` | Future sprint | +| `genie agent exec` | Use tmux directly | +| `genie agent ship` | Merged into `genie done` | +| `genie agent events` | Internal only, not a user command | +| `genie _open [team]` | Incorporated into session flow | +| `genie team ensure` | Absorbed by `team create` | +| `genie team blueprints` | Dropped | +| `genie profiles *` (5 commands) | Replaced by directory | +| `genie daemon *` (4 commands) | Beads removed | +| `genie ledger *` (2 commands) | Beads removed | +| `genie brainstorm crystallize` | Beads integration, broken | +| `genie work ` (old) | Replaced by dispatch commands | +| `genie task *` (10 commands) | Replaced by state machine. Issue opened to monitor if sub-group granularity (option C) needed later | +| `genie council` (old command) | Replaced by `genie team hire council` + skill | + +--- + +## Skill Prompt Review + +### Skills Needing `/refine` Pass + +All skills that dispatch subagents or interact with the orchestration model need updating: + +| Skill | Key Changes | +|---|---| +| **brainstorm** | Multi-agent aware. Reads/writes in shared worktree. Acknowledges injected context from dispatch. Multiple agents can edit same DRAFT.md | +| **wish** | Collaborative. Creates state file with group definitions + dependency graph. Reads design from shared worktree. Back-and-forth via messaging | +| **work** | Does NOT manage state (no checkboxes, no `bd close`). Receives group context from dispatch. Signals completion to leader via message. Uses `genie spawn` for subagent dispatch | +| **review** | Receives scope from dispatch. Council can participate via team chat. Uses `genie spawn` for dispatch. Posts findings to team chat | +| **fix** | Uses `genie spawn` for fixer/reviewer dispatch. Max 2 loops unchanged | +| **dream** | Uses new team/worktree model. Creates teams per wish. Uses `genie work` for dispatch. State machine for tracking | +| **council** | Two modes: (1) Lightweight = same as today, simulated in one session. (2) Full spawn = `genie team hire council`, real agents discuss in team chat, leader makes final call | +| **trace** | Uses `genie spawn` for dispatch. Hands off to `/fix` unchanged | +| **onboarding** | Update for new directory model (`genie dir add`), new team model, new session naming | +| **docs** | Uses `genie spawn` for dispatch | + +### Skills Unchanged +| Skill | Reason | +|---|---| +| **report** | Independent of orchestration. Uses `/trace` internally | +| **brain** | Independent. Knowledge vault via notesmd-cli | +| **refine** | Independent. Prompt optimizer, no dispatch | +| **learn** | Independent. Behavioral config only | + +### Cross-Cutting Changes (all refined skills) +1. **Dispatch method:** `genie spawn ` replaces `Task tool` / `genie agent spawn --role` +2. **File paths:** `.genie/` in shared worktree, not repo root +3. **State management:** `/work` no longer manages state — transitions happen via `genie work`/`genie done` +4. **Context injection:** Skills acknowledge injected context (file path + extracted section) from dispatch commands +5. **Role separation preserved:** Never combine implementor+reviewer, fixer+reviewer, tracer+fixer in same session + +--- + +## Schemas + +### Agent Directory Entry +```typescript +interface DirectoryEntry { + name: string; // globally unique + dir: string; // agent folder (CWD + AGENTS.md) + repo?: string; // default git repo (overridden by team) + promptMode: 'system' | 'append'; + model?: string; // default model (sonnet, opus, codex) + roles?: string[]; // built-in roles this agent can orchestrate + registeredAt: string; +} +``` + +### Team +```typescript +interface Team { + name: string; // = branch name (e.g., "fix/auth-bug") + repo: string; // git repo path (required) + baseBranch: string; // branch to create worktree from (default: "dev") + worktreePath: string; // / + leader: string; // leader's session reference + members: string[]; // agent names (directory or built-in) + createdAt: string; +} +``` + +### Wish State +```typescript +interface WishState { + wish: string; // slug + groups: Record; +} + +interface GroupState { + status: 'blocked' | 'ready' | 'in_progress' | 'done'; + assignee?: string; // agent name + dependsOn?: number[]; // group numbers + startedAt?: string; + completedAt?: string; +} +``` + +### Genie Config (relevant additions) +```typescript +// In ~/.genie/config.json +{ + terminal: { + worktreeBase: string; // default: '.worktrees' + } +} +``` + +--- + +## Naming Conventions +| Pattern | Convention | +|---|---| +| List anything | `ls` | +| Remove anything | `rm` | +| Add anything | `add` | +| Create group entity | `create` | +| Destroy group entity | `disband` | +| Add member | `hire` | +| Remove member | `fire` | + +--- + +## Risks + +| ID | Risk | Severity | Mitigation | +|----|------|----------|------------| +| R1 | **State file abandonment** — agents don't run `genie done`, groups stay `in_progress` forever | High | State transitions are genie commands, not agent behavior. Orchestrator tracks at prompt level. Leader follows up on stale `in_progress` | +| R2 | **Concurrent worktree edits** — multiple agents editing same files in shared worktree | Medium | Git handles file-level conflicts. Wish groups should be scoped to non-overlapping files. Review catches integration issues | +| R3 | **Built-in vs directory collision** — user registers agent with same name as built-in | Low | User directory wins (explicit override). Clear resolution order documented | +| R4 | **Multi-team agent confusion** — agent in 3 teams, receives message, which context? | Medium | Send is scoped to own team. Agent receives team context with each dispatch. Genie tracks which team each session belongs to | +| R5 | **`--system-prompt-file` undocumented** — hidden Claude Code flag could change/break | Medium | Test in CI. Flag confirmed working today. If removed, fall back to `--system-prompt "$(cat)"` pattern | +| R6 | **Skill prompt drift** — 10 skills need `/refine` pass, high effort | Medium | Prioritize core chain (brainstorm→wish→work→review). Others can be refined incrementally | +| R7 | **Council as real agents** — 10 agents spawned = 10 Claude sessions = cost | Low | Leader chooses subset (smart routing). Full council is rare. Lightweight mode (skill) exists for cheap reviews | + +--- + +## Acceptance Criteria + +### AC1: Agent Directory +- `genie dir add totvs-pm --dir ~/agents/pm --prompt-mode system` persists entry +- `genie dir ls` shows all registered agents +- `genie dir ls totvs-pm` shows single entry details +- `genie dir rm totvs-pm` removes entry +- `genie dir edit totvs-pm --model opus` updates entry +- Directory entry supports: name, dir, repo, promptMode, model, roles + +### AC2: Spawn from Directory +- `genie spawn totvs-pm` resolves from directory, sets CWD to `dir`, injects AGENTS.md via `--[append-]system-prompt-file` +- Prompt mode from directory entry determines which flag is used +- Model from directory entry (or `--model` override) is passed to Claude Code +- Spawning a built-in role works without directory registration: `genie spawn implementor` +- Agent spawned outside team context → CWD = agent dir +- Agent spawned in team context → CWD = team worktree + +### AC3: Team Lifecycle +- `genie team create feat/my-feature --repo ~/repos/genie --branch dev` creates worktree at `/feat/my-feature`, branch `feat/my-feature` from `dev` +- `genie team create` is idempotent (re-running doesn't fail) +- `genie team hire totvs-engineer` adds to team (auto-detects team from leader context) +- `genie team hire council` hires all 10 council members +- `genie team fire totvs-engineer` removes from team +- `genie team ls` lists all teams. `genie team ls feat/my-feature` lists members +- `genie team disband feat/my-feature` kills all members, cleans up worktree + +### AC4: State Machine +- `genie work totvs-engineer auth-bug#2` checks dependencies → sets group 2 to `in_progress` → spawns agent with context +- `genie work` refuses if dependencies not met ("group 1 is not done") +- `genie done auth-bug#2` sets group to `done`, unblocks dependents +- `genie status auth-bug` shows all groups with status, assignee, timestamps +- State file lives at `.genie/state/.json` in shared worktree +- Agents never read or write the state file directly + +### AC5: Dispatch Commands +- `genie brainstorm ` spawns with DRAFT.md path + content injected +- `genie wish ` spawns with DESIGN.md path + content injected +- `genie work #` extracts group content, injects with wish file path +- `genie review #` injects group + PR/diff context +- All dispatch commands pass the file path so agent can read the full document + +### AC6: Messaging +- `genie send 'msg' --to totvs-engineer` delivers without `--team` flag +- Send is scoped: leader can only message own team members +- `genie broadcast 'msg'` delivers to all team members (leader only) +- `genie chat 'msg'` posts to team group channel +- `genie chat read` shows channel history +- Message to offline agent triggers auto-spawn + delivery + +### AC7: Flat Naming +- All listing commands use `ls` +- All remove commands use `rm` +- All add commands use `add` +- `genie stop` (not suspend), `genie done` (not close/ship) + +### AC8: Skill Prompt Alignment +- 10 skills updated via `/refine` to use `genie spawn` for dispatch +- `/work` does NOT manage state — receives context, signals completion via message +- `/brainstorm` and `/wish` read/write in shared worktree `.genie/` +- `/council` supports two modes: lightweight (skill) and full spawn (team) +- All skills acknowledge injected context from dispatch commands + +### AC9: Cleanup +- All 10 `genie task *` commands removed +- All `genie profiles *` commands removed (replaced by directory) +- All `genie daemon *` commands removed (beads removed) +- All `genie ledger *` commands removed (beads removed) +- `genie agent *` namespace removed (promoted to top-level) +- `genie team ensure`, `genie team blueprints` removed +- `genie _open`, `genie agent dashboard/watchdog/approve/exec/ship` removed +- Issue opened to monitor if sub-group task granularity needed later + +--- + +## Decisions Made (complete — 36 decisions) +1. Native `--system-prompt-file` / `--append-system-prompt-file` ✅ +2. One folder per agent ✅ +3. Repo at team level, optional at agent level, team overrides ✅ +4. Per-agent prompt mode in directory entry ✅ +5. Per-agent default model in directory entry ✅ +6. Optional roles declaration in directory entry ✅ +7. No backward compat ✅ +8. Agent names globally unique ✅ +9. Flat messaging by name, scoped to own team ✅ +10. Agents can be in multiple teams ✅ +11. `suspend` → `stop` ✅ +12. Team = dynamic hire/fire ✅ +13. Team name = branch name (conventional git prefixes) ✅ +14. Worktree base configurable, default `.worktrees` ✅ +15. Broadcast (leader-only) + Chat (group channel) ✅ +16. Auto-spawn on message to offline agent ✅ +17. `close` + `ship` → `done` (state transition) ✅ +18. Profiles replaced by directory ✅ +19. Blueprints dropped ✅ +20. Dashboard, watchdog, approve, exec dropped ✅ +21. `_open` removed, session flow replaces it ✅ +22. `ensure` absorbed into `team create` (idempotent) ✅ +23. Naming: ls/rm/add consistently ✅ +24. Commands promoted to top-level (no `agent` namespace) ✅ +25. `genie` no args = persistent session ✅ +26. Context files live in shared worktree `.genie/` ✅ +27. Beads replaced by wish-native state file ✅ +28. State machine: only genie commands mutate state, agents never touch it ✅ +29. Dispatch commands (brainstorm/wish/work/review) inject context + manage state ✅ +30. Brainstorm/wish are now multi-agent with optional human participation ✅ +31. Skill prompts must be refined to align with new orchestration model ✅ +32. Council: lightweight (skill) + full spawn (team hire) modes ✅ +33. Tasks die completely — wish groups are the only unit of work ✅ +34. Agents ≠ Roles — separate concepts, dynamic orchestration ✅ +35. Built-in roles + council ship with genie package ✅ +36. Council hired as group: `genie team hire council` (all or none) ✅ diff --git a/.genie/wishes/fix-onboarding-prod-bugs/WISH.md b/.genie/wishes/fix-onboarding-prod-bugs/WISH.md new file mode 100644 index 000000000..c166748f1 --- /dev/null +++ b/.genie/wishes/fix-onboarding-prod-bugs/WISH.md @@ -0,0 +1,153 @@ +# Wish: Fix Onboarding Production Bugs (tmux defaults + silent prompt failure) + +| Field | Value | +|-------|-------| +| **Status** | SUPERSEDED by `unify-install-kill-fragmentation` | +| **Slug** | `fix-onboarding-prod-bugs` | +| **Date** | 2026-03-09 | + +## Summary + +Two bugs discovered during clean-machine onboarding testing. Bug 1 (low): `genie install` never writes base tmux settings, so users on distros with `mouse on` system defaults can't scroll. Bug 2 (critical): `TEAM_LEAD_PROMPT.md` is loaded via filesystem path that resolves correctly in dev (`src/lib/`) but breaks in the production flat bundle (`dist/genie.js`), causing Claude to launch without any orchestration instructions — silently. + +## Scope + +### IN + +- Inline `TEAM_LEAD_PROMPT.md` content as a TypeScript constant (eliminate filesystem dependency) +- Add explicit CRITICAL-level warning when orchestration prompt is empty/null +- Fix misleading `tui.ts` warning that only mentions `AGENTS.md` +- Add sensible tmux base defaults to `generateTmuxConfig()` in shortcuts.ts +- Keep `TEAM_LEAD_PROMPT.md` file for documentation/reference (add header noting it's inlined) + +### OUT + +- No changes to the build pipeline or bundler configuration +- No changes to `AGENTS.md` loading logic (that works correctly via `process.cwd()`) +- No tmux mouse-mode detection or auto-configuration beyond static defaults +- No changes to `install.ts` prerequisite installation flow +- No new CLI flags or user-facing options + +## Decisions + +| Decision | Rationale | +|----------|-----------| +| Inline prompt as TS constant (Option 1) | Eliminates entire class of "file not found at runtime" bugs. No bundler config needed. Content already ships with the package — just needs to be compiled in. | +| Keep `TEAM_LEAD_PROMPT.md` file | Serves as human-readable documentation; easier to review/edit prompt content before copying into the constant. | +| `set -g mouse off` as default | Users on clean machines expect terminal-native scroll. Power users who want mouse can override in their own config. Matches tmux's own default (pre-distro overrides). | +| Add CRITICAL warning, not throw | Throwing would block `genie command` entirely. Warning lets it proceed degraded while making the failure visible. | + +## Success Criteria + +- [ ] `genie command` on a clean install injects `TEAM_LEAD_PROMPT.md` content into the system prompt +- [ ] Running from `dist/genie.js` (production bundle) produces identical orchestration prompt as running from `src/genie.ts` (dev) +- [ ] If orchestration prompt is somehow empty, a CRITICAL warning is logged to stderr +- [ ] `generateTmuxConfig()` output includes `set -g mouse off` and `set -g base-index 0` +- [ ] `tui.ts` warning distinguishes between missing `AGENTS.md` (optional) and missing orchestration prompt (critical) +- [ ] `bun run check` passes (typecheck + lint + dead-code + tests) +- [ ] No runtime `fs.readFileSync` or `fs.existsSync` calls for `TEAM_LEAD_PROMPT.md` + +## Execution Groups + +### Group 1: Inline orchestration prompt (CRITICAL fix) + +**Goal:** Eliminate filesystem dependency for TEAM_LEAD_PROMPT.md. Make the orchestration prompt a compile-time constant. + +**Deliverables:** +1. Create a new constant `TEAM_LEAD_PROMPT` in `src/lib/team-lead-command.ts` containing the full content of `TEAM_LEAD_PROMPT.md` +2. Replace `getTeamLeadPrompt()` filesystem-based function with a simple getter returning the constant +3. Remove unused `fs` imports (`readFileSync`, `existsSync` for prompt loading — keep others if used elsewhere) +4. Remove unused `path` imports (`dirname`, `join` for prompt path — keep if used elsewhere) +5. Remove unused `url` import (`fileURLToPath` — keep if used elsewhere) + +**Acceptance criteria:** +- `getTeamLeadPrompt()` returns the prompt content without any filesystem access +- No `import.meta.url` usage remains for prompt resolution +- The returned string matches `TEAM_LEAD_PROMPT.md` content exactly + +**Validation:** +```bash +# Verify no filesystem loading for the prompt +grep -n 'import.meta.url' src/lib/team-lead-command.ts && echo "FAIL: import.meta.url still present" || echo "PASS" +grep -n 'TEAM_LEAD_PROMPT' src/lib/team-lead-command.ts | head -5 +bun run typecheck +``` + +### Group 2: Add failure warnings + fix misleading tui.ts message + +**Goal:** Make prompt loading failures loud and distinguishable. + +**Deliverables:** +1. In `persistSystemPrompt()` (`team-lead-command.ts`), add a `console.error('CRITICAL: ...')` if `getTeamLeadPrompt()` returns empty/null +2. In `tui.ts`, update the warning at lines 198-201: + - If `AGENTS.md` is missing, log it as informational (not a problem) + - After `buildClaudeCommand` is called, if no `--system-prompt` flag was emitted, log a CRITICAL warning mentioning both AGENTS.md and orchestration prompt + +**Acceptance criteria:** +- Warning text clearly distinguishes optional `AGENTS.md` from mandatory orchestration prompt +- CRITICAL warning fires when orchestration prompt is null/empty +- Warning does NOT fire during normal operation (prompt is inlined, so it should always be present) + +**Validation:** +```bash +grep -n 'CRITICAL' src/lib/team-lead-command.ts src/genie-commands/tui.ts +bun run typecheck +``` + +### Group 3: tmux base defaults + +**Goal:** Ensure clean-machine tmux installs get sensible defaults. + +**Deliverables:** +1. Prepend base settings to `generateTmuxConfig()` output in `src/term-commands/shortcuts.ts`: + ``` + # Base settings (generated by genie-cli) + set -g mouse off + set -g base-index 0 + setw -g pane-base-index 0 + ``` + +**Acceptance criteria:** +- `generateTmuxConfig()` output starts with base settings before keyboard shortcuts +- Existing keyboard shortcuts remain unchanged +- The "generated by genie-cli" marker is present (used for install/uninstall detection) + +**Validation:** +```bash +grep -n 'mouse off' src/term-commands/shortcuts.ts && echo "PASS" || echo "FAIL" +grep -n 'base-index' src/term-commands/shortcuts.ts && echo "PASS" || echo "FAIL" +bun run typecheck +``` + +### Group 4: Update TEAM_LEAD_PROMPT.md + final validation + +**Goal:** Mark the file as documentation-only and run full quality gates. + +**Deliverables:** +1. Add a header comment to `TEAM_LEAD_PROMPT.md`: + ``` + + ``` +2. Run full quality gates + +**Acceptance criteria:** +- `bun run check` exits 0 +- `bun run build` succeeds +- Built `dist/genie.js` contains the inlined prompt string + +**Validation:** +```bash +bun run check +bun run build +grep -c 'MANDATORY Agent Orchestration' dist/genie.js # should be >= 1 +``` + +## Assumptions / Risks + +| Risk | Mitigation | +|------|------------| +| Inlined prompt constant becomes stale vs. TEAM_LEAD_PROMPT.md edits | Header comment in .md file warns to sync changes; could add a CI check later | +| `set -g mouse off` may surprise users who expect mouse | Matches tmux's own compiled default; users can override in their own config block below genie's | +| Large string constant in source may trigger linter warnings | Use template literal; biome doesn't flag long template strings | diff --git a/.genie/wishes/genie-v2-framework-redesign/WISH.md b/.genie/wishes/genie-v2-framework-redesign/WISH.md new file mode 100644 index 000000000..5656b3a43 --- /dev/null +++ b/.genie/wishes/genie-v2-framework-redesign/WISH.md @@ -0,0 +1,546 @@ +# Wish: Genie CLI v2 — Complete Framework Redesign + +| Field | Value | +|-------|-------| +| **Status** | DRAFT | +| **Slug** | `genie-v2-framework-redesign` | +| **Date** | 2026-03-13 | +| **Design** | [DESIGN.md](../../brainstorms/agent-directory/DESIGN.md) | +| **Draft** | [DRAFT.md](../../brainstorms/agent-directory/DRAFT.md) | + +## Summary + +Redesign the entire genie CLI framework: replace 40+ commands with a streamlined command tree, introduce an agent directory for identity management, replace beads with a wish-native state machine, add team-based worktree collaboration with dynamic hire/fire, implement dispatch commands (brainstorm/wish/work/review) with context injection, and refine 10 skill prompts to align with the new orchestration model. Clean break — no backward compatibility. + +## Scope + +### IN + +- Agent directory module (`genie dir add/rm/ls/edit`) replacing profiles +- Directory-based spawn (`genie spawn `) with `--system-prompt-file` / `--append-system-prompt-file` +- Built-in roles and council members shipping with genie package +- Team lifecycle (`genie team create/hire/fire/disband`) with git worktree management +- Team name = branch name (conventional git prefixes) +- Wish-native state machine (`genie work/done/status`) replacing beads +- Dispatch commands with context injection (brainstorm/wish/work/review) +- Flat messaging by name (`genie send/broadcast/chat`) scoped to own team +- Auto-spawn on message to offline agent +- Promote agent commands to top-level (`spawn`, `kill`, `stop`, `ls`, etc.) +- `/refine` pass on 10 skills to align with new orchestration model +- Council dual-mode: lightweight (skill) + full spawn (team hire) +- Remove 25+ deprecated commands (beads, profiles, blueprints, task, old agent namespace) +- `genie --session ` for named leader sessions + +### OUT + +- Changes to Claude Code's native teammate protocol +- New messaging transport (still mailbox + native inbox) +- Permission system / approve workflow (future sprint) +- Watchdog replacement (future external service) +- Sub-group task granularity (issue opened to monitor need) +- Multi-project per agent +- Changes to non-orchestration skills (brain, refine, learn, report) +- Changes to build pipeline or bundler +- `install.sh` review (separate effort) + +## Decisions + +| Decision | Rationale | +|----------|-----------| +| `--system-prompt-file` / `--append-system-prompt-file` | Hidden but confirmed working. Eliminates persistSystemPrompt + $(cat) pattern entirely | +| One folder per agent | CWD = identity source. Simplest model. No home/project split | +| Repo at team level, optional at agent level | Team repo overrides agent repo. All team members in same worktree | +| Per-agent promptMode, model, roles in directory | Agent knows its own capabilities without relying on prompting | +| No backward compat | Clean break. Old patterns replaced, not preserved alongside new | +| Team name = branch name | `feat/agent-directory` is both the team name and the git branch. No translation | +| Beads replaced by wish-native state file | Simpler, shared via worktree, no daemon/ledger/sync overhead | +| State transitions via genie commands only | Agents never touch state file. Prevents abandonment. Leader tracks guarantees | +| Agents ≠ Roles | Agents have identity. Roles are ephemeral built-ins. Dynamic orchestration decides who bosses whom | +| Council: all or none | `genie team hire council` hires all 10. No per-member hiring | +| Tasks die completely | Wish groups are the only unit of work. Monitor for sub-group need | +| Naming: ls/rm/add consistently | Git-familiar conventions throughout | +| `suspend` → `stop` | Clearer intent: stop current run, keep pane alive | +| `close` + `ship` → `done` | Single state transition command | +| Dispatch commands inject file path + extracted content | Agent gets full context without searching. Reduces token waste | + +## Success Criteria + +- [ ] `genie dir add/rm/ls/edit` fully operational, persists to `~/.genie/agent-directory.json` +- [ ] `genie spawn ` resolves from directory, injects AGENTS.md via correct `--*-system-prompt-file` flag +- [ ] `genie spawn implementor` works for built-in roles without directory registration +- [ ] `genie team create feat/x --repo --branch dev` creates worktree, starts leader session +- [ ] `genie team hire/fire` manages dynamic membership, `hire council` hires all 10 +- [ ] `genie team disband` kills members + cleans up worktree +- [ ] `genie work #` checks deps → sets in_progress → spawns with context +- [ ] `genie done #` transitions state, unblocks dependents +- [ ] `genie status ` shows all groups with state/assignee/timestamps +- [ ] `genie send` routes by name without `--team`, scoped to own team +- [ ] `genie broadcast` delivers to all team members +- [ ] `genie chat` / `genie chat read` posts to/reads team group channel +- [ ] Message to offline registered agent triggers auto-spawn + delivery +- [ ] 10 skill prompts updated to use `genie spawn` dispatch + acknowledge injected context +- [ ] `/work` skill does NOT manage state — receives context, signals completion via message +- [ ] `/council` supports lightweight (skill) and full spawn (team hire) modes +- [ ] All `genie task *`, `genie profiles *`, `genie daemon *`, `genie ledger *` commands removed +- [ ] `genie agent *` namespace removed — all promoted to top-level +- [ ] `bun run check` passes +- [ ] `bun run build` succeeds + +## Execution Groups + +### Group 1: Agent Directory Module + +**Goal:** Persistent agent registry with CRUD operations, replacing profiles. + +**Deliverables:** +1. Create `src/lib/agent-directory.ts` — JSON registry at `~/.genie/agent-directory.json` + - Schema: `{ name, dir, repo?, promptMode, model?, roles?, registeredAt }` + - Public API: `add()`, `rm()`, `resolve()`, `ls()`, `edit()`, `loadIdentity()` + - File-lock pattern (same as agent-registry.ts) for concurrent access + - Path validation on `add` (dir must exist, AGENTS.md must exist in dir) +2. Create built-in agents registry — `src/lib/builtin-agents.ts` + - 10 built-in roles (implementor, tester, reviewer, debugger, verifier, investigator, reproducer, dreamer, critic, security) + - Role prompts: derive from existing blueprint descriptions + Claude agent definitions in the codebase. Each role gets a short system prompt (1-2 paragraphs) defining its purpose, constraints, and output expectations. + - 10 council members with default models and lens prompts (sourced from `skills/council/SKILL.md` member table) + - Resolution: user directory > built-in registry +3. Add CLI subcommands in new `src/term-commands/dir.ts` + - `genie dir add --dir --repo --prompt-mode --model --roles` + - `genie dir rm ` + - `genie dir ls []` + - `genie dir edit --dir --repo --prompt-mode --model --roles` +4. Remove `src/lib/team-manager.ts` blueprint system (BLUEPRINTS constant, getBlueprint, listBlueprints) +5. Remove `genie profiles *` commands and related code (list, add, rm, show, default) +6. Remove `genie team blueprints` command + +**Acceptance criteria:** +- `genie dir add test-agent --dir /tmp/test --prompt-mode append` persists entry +- `genie dir ls` lists all registered agents +- `genie dir ls test-agent` shows entry details +- `genie dir edit test-agent --model opus` updates entry +- `genie dir rm test-agent` removes entry +- `resolve("implementor")` returns built-in when no user override exists +- `resolve("test-agent")` returns user entry (overrides built-in if same name) +- Profiles commands no longer exist +- `genie team blueprints` no longer exists + +**Validation:** +```bash +bun run typecheck +bun test src/lib/agent-directory.test.ts +bun test src/term-commands/dir.test.ts +``` + +**depends-on:** none + +--- + +### Group 2: Directory-Based Spawn + +**Goal:** `genie spawn ` resolves from directory, injects identity via native file flags. + +**Deliverables:** +1. Modify `src/lib/provider-adapters.ts` + - Add `systemPromptFile?: string` and `promptMode?: 'system' | 'append'` to `SpawnParams` + - In `buildClaudeCommand()`: if `systemPromptFile` provided, add `--system-prompt-file` or `--append-system-prompt-file` based on `promptMode` + - Add optional `model?: string` to SpawnParams, pass as `--model` flag + - Remove `persistSystemPrompt()` from `team-lead-command.ts` and all callers +2. Rewrite `genie spawn` in `src/term-commands/agents.ts` + - Change signature: `genie spawn [--model] [--team]` + - Resolution: directory.resolve(name) → if found, use entry. If not found, check built-in. If neither, error. + - CWD: if agent in team → team worktree. If solo → entry.dir (or built-in default) + - Identity: `loadIdentity(name)` → `--[append-]system-prompt-file /AGENTS.md` + - Set `GENIE_AGENT_NAME=` in launch env +3. Remove `--role` as required option (name is the primary arg) +4. Update `src/lib/team-lead-command.ts` to use `--append-system-prompt-file` instead of `$(cat)` pattern + +**Acceptance criteria:** +- `genie spawn test-agent` resolves from directory, CWD = dir, AGENTS.md injected via `--append-system-prompt-file` +- `genie spawn test-agent --model opus` overrides directory default model +- `genie spawn implementor` works for built-in roles (no registration needed) +- Agent with `promptMode: 'system'` uses `--system-prompt-file` +- Agent with `promptMode: 'append'` uses `--append-system-prompt-file` +- `persistSystemPrompt()` and `$(cat)` pattern no longer exist in codebase + +**Validation:** +```bash +bun run typecheck +grep -rn 'persistSystemPrompt\|\\$\\(cat' src/ && echo "FAIL: old pattern exists" || echo "PASS" +bun test src/lib/provider-adapters.test.ts +``` + +**depends-on:** Group 1 + +--- + +### Group 3: Team Lifecycle & Worktree Management + +**Goal:** Dynamic team creation with git worktree, hire/fire membership, disband with cleanup. + +**Deliverables:** +1. Rewrite `src/lib/team-manager.ts` + - New Team schema: `{ name, repo, baseBranch, worktreePath, leader, members[], createdAt }` + - `createTeam(name, repo, baseBranch)`: git pull → git worktree add → persist team config + - Worktree path: `/` (worktreeBase from config, default `.worktrees`) + - Team name = branch name (e.g., `feat/agent-directory`) + - `createTeam` is idempotent (re-running doesn't fail if team exists) + - `hireAgent(teamName, agentName)`: add to members array. Special case: `council` hires all 10. + - `fireAgent(teamName, agentName)`: remove from members, kill agent if running + - `disbandTeam(teamName)`: kill all members, remove git worktree, delete team config + - `getTeam()`, `listTeams()`, `listMembers()` +2. Rewrite `src/term-commands/team.ts` + - `genie team create --repo [--branch dev]` + - `genie team hire [--team ]` — auto-detect team from leader context if no `--team` + - `genie team hire council` — hire all 10 council members + - `genie team fire [--team ]` + - `genie team ls []` — no arg = teams, with arg = members + - `genie team disband ` +3. Remove: `genie team ensure`, blueprint-related code +4. Remove `genie _open [team]` hidden command (functionality absorbed by team create + session flow) +5. Update genie config schema: ensure `terminal.worktreeBase` is supported (default: `.worktrees`) + +**Acceptance criteria:** +- `genie team create feat/test --repo /path --branch dev` creates worktree at `/feat/test`, branch `feat/test` from `dev` +- Re-running `team create` for existing team doesn't fail +- `genie team hire agent-name` adds to team (auto-detects team from leader context) +- `genie team hire council` adds all 10 council members +- `genie team fire agent-name` removes from team +- `genie team ls` lists all teams. `genie team ls feat/test` lists members +- `genie team disband feat/test` kills members + removes worktree +- `ensure` and `blueprints` commands no longer exist + +**Validation:** +```bash +bun run typecheck +bun test src/lib/team-manager.test.ts +bun test src/term-commands/team.test.ts +``` + +**depends-on:** Group 1 + +--- + +### Group 4: Wish State Machine + +**Goal:** Replace beads with wish-native state file. Deterministic state transitions via genie commands only. + +**Deliverables:** +1. Create `src/lib/wish-state.ts` + - Schema: `WishState { wish, groups: Record }` + - `GroupState { status: 'blocked'|'ready'|'in_progress'|'done', assignee?, dependsOn?, startedAt?, completedAt? }` + - State file: `.genie/state/.json` in CWD (shared worktree) + - `createState(slug, groups)`: initialize from wish group definitions + - `startGroup(slug, group, assignee)`: check deps → set `in_progress` → write. Refuses if deps not met. + - `completeGroup(slug, group)`: set `done` → recalculate dependent groups (blocked→ready) + - `getState(slug)`: read current state + - `getGroupState(slug, group)`: read single group + - File-lock for concurrent access +2. Add CLI commands (new `src/term-commands/state.ts` or inline in existing) + - `genie done #` — calls `completeGroup()` + - `genie status ` — pretty-prints all groups with status, assignee, timestamps +3. Remove beads integration + - Remove `genie daemon *` commands (start/stop/status/restart) + - Remove `genie ledger *` commands (validate, work) + - Remove `genie brainstorm crystallize` (beads JSONL integration) + - Remove beads-related imports and references throughout codebase + - Remove `bd` CLI calls from all commands (close, ship, work, etc.) + +**Acceptance criteria:** +- State file created at `.genie/state/.json` with correct group structure +- `startGroup` refuses when dependencies not met (returns error) +- `startGroup` sets `in_progress` with timestamp and assignee +- `completeGroup` sets `done`, recalculates dependent group statuses +- `genie done slug#2` works from CLI +- `genie status slug` shows readable state overview +- No `bd` or beads references remain in codebase +- `genie daemon *` and `genie ledger *` commands no longer exist + +**Validation:** +```bash +bun run typecheck +bun test src/lib/wish-state.test.ts +grep -rn '\bbd\b\|beads\|daemon\|ledger' src/ --include='*.ts' | grep -v test | grep -v '.genie/' && echo "FAIL: beads refs remain" || echo "PASS" +``` + +**depends-on:** none + +--- + +### Group 5: Dispatch Commands + +**Goal:** Context-injecting dispatch commands that bridge the state machine and agent spawn. + +**Deliverables:** +1. Create `src/term-commands/dispatch.ts` + - `genie brainstorm ` — reads `.genie/brainstorms//DRAFT.md`, spawns agent with content + file path injected, agent enters `/brainstorm` + - `genie wish ` — reads `.genie/brainstorms//DESIGN.md`, spawns agent with design + file path, agent enters `/wish` + - `genie work #` — reads `.genie/wishes//WISH.md`, extracts specific group, calls `wishState.startGroup()`, spawns agent with group context + wish file path + - `genie review #` — reads wish group + git diff context, spawns agent with review scope +2. Context injection pattern (shared utility): + - Build prompt: file path to full document + extracted section content + wish-level context (summary, scope, decisions) + - Pass via `--append-system-prompt` or temp file approach for long content +3. Slug#group parsing utility: `parseRef("auth-bug#2")` → `{ slug: "auth-bug", group: "2" }` +4. Integration with spawn: dispatch calls `spawn` internally after state check + +**Acceptance criteria:** +- `genie brainstorm agent-name slug` spawns with DRAFT.md content + path injected +- `genie wish agent-name slug` spawns with DESIGN.md content + path injected +- `genie work agent-name slug#2` checks state → sets in_progress → spawns with group 2 content +- `genie work agent-name slug#3` refuses if group 2 not done (dependency enforcement) +- `genie review agent-name slug#2` spawns with group + diff context +- All dispatch commands pass the file path so agent can read the full document + +**Validation:** +```bash +bun run typecheck +bun test src/term-commands/dispatch.test.ts +``` + +**depends-on:** Group 2, Group 4 + +--- + +### Group 6: Messaging Redesign + +**Goal:** Flat routing by name, team-scoped send, broadcast, group chat, auto-spawn. + +**Deliverables:** +1. Rewrite `src/term-commands/msg.ts` + - `genie send '' --to ` — resolve by name from directory (no `--team` needed) + - Scope check: if sender is in a team, recipient must be in same team + - Remove `--team` from send command + - `genie broadcast ''` — leader sends to all team members (one-way) + - `genie inbox [] [--unread]` — same functionality, improved resolution +2. Create `src/lib/team-chat.ts` + - Group channel per team: `/.genie/chat/.jsonl` (lives in shared worktree so all members can read) + - `postMessage(team, sender, body)`: append to channel + - `readMessages(team, since?)`: read channel history +3. Add chat commands in `src/term-commands/msg.ts` + - `genie chat '' [--team ]` — post to team channel (auto-detect team from context) + - `genie chat read [--team ] [--since ]` +4. Rewrite `src/lib/protocol-router.ts` for directory-first resolution + - Resolution order: directory by name → built-in by name → worker registry fallback + - Auto-spawn: if agent offline + in directory → spawn → deliver +5. Update `src/hooks/handlers/auto-spawn.ts` for directory awareness + - Check directory before templates for offline agent resolution + +**Acceptance criteria:** +- `genie send 'hello' --to agent-name` delivers without `--team` +- Send from team member to non-team-member is rejected (scope enforcement) +- `genie broadcast 'update'` delivers to all members of sender's team +- `genie chat 'discussion point'` posts to team channel +- `genie chat read` shows channel history +- Message to offline registered agent triggers auto-spawn + delivery +- `--team` flag no longer exists on `send` command + +**Validation:** +```bash +bun run typecheck +bun test src/term-commands/msg.test.ts +bun test src/lib/protocol-router.test.ts +bun test src/lib/team-chat.test.ts +``` + +**depends-on:** Group 1, Group 2, Group 3 + +--- + +### Group 7: Command Promotion, Namespace Removal & Session + +**Goal:** Promote agent commands to top-level, remove `genie agent` namespace, add `--session`, implement `genie ls` smart view. Deprecated command removal is distributed across earlier groups (profiles in G1, blueprints/ensure/_open in G3, beads in G4). This group handles the remaining removals and the namespace restructure. + +**Deliverables:** +1. Promote commands in `src/term-commands/agents.ts` and `src/genie.ts` + - `genie spawn ` (was `genie agent spawn`) — already rewritten in G2 + - `genie kill ` (was `genie agent kill `) + - `genie stop ` (was `genie agent suspend `) — rename suspend→stop + - `genie history ` (was `genie agent history `) + - `genie read ` (was `genie agent read `) + - `genie answer ` (was `genie agent answer `) + - All resolve by agent name, not pane ID or worker ID +2. Remove remaining deprecated commands not handled by earlier groups + - `genie agent dashboard` + - `genie agent watchdog` + - `genie agent approve` + - `genie agent exec` + - `genie agent ship` + - `genie agent close` + - `genie agent events` (keep internal module, remove CLI command) + - `genie task *` (all 10: create, update, ship, close, ls, link, unlink, create-local, list-local, update-local) + - `genie council` (old command — replaced by `genie team hire council` + skill) +3. Remove `genie agent` namespace entirely — top-level commands only +4. Add `genie --session ` to entry point for named leader sessions + - Maintains name→UUID mapping internally + - `genie --session mywork` starts a new named session + - `genie --session mywork` again resumes it +5. Implement `genie ls` smart view + - Default: shows registered agents with runtime status (running/idle/offline) and current team + - Output: `NAME | DIR | STATUS | TEAM | MODEL` + - Built-in roles only shown when running (not in idle listing) + +**Acceptance criteria:** +- All promoted commands work at top level: `genie kill`, `genie stop`, `genie history`, `genie read`, `genie answer` +- `genie agent *` namespace no longer exists +- `genie task *` namespace no longer exists +- `genie --session mywork` starts a new named session +- `genie --session mywork` again resumes the same session (identified by name, not UUID) +- `genie ls` shows registered agents with runtime status and team membership +- `genie stop ` stops current run but keeps pane alive +- Old commands (dashboard, watchdog, approve, exec, ship, close, events, council) no longer exist + +**Validation:** +```bash +bun run typecheck +grep -rn "command('agent')" src/ && echo "FAIL: agent namespace exists" || echo "PASS" +grep -rn "command('task')" src/ && echo "FAIL: task namespace exists" || echo "PASS" +grep -rn "command('profiles')" src/ && echo "FAIL: profiles namespace exists" || echo "PASS" +``` + +**depends-on:** Group 2, Group 3 + +--- + +### Group 8: Council Refactor + +**Goal:** Council supports two modes — lightweight (skill in single session) and full spawn (real agents in team). + +**Deliverables:** +1. Update built-in agents registry (`src/lib/builtin-agents.ts`) with council members + - 10 members with: name, lens prompt (from current SKILL.md), default model + - Smart routing table preserved (architecture, performance, security, etc.) +2. Implement `genie team hire council` in team manager + - Hires all 10 council members into the team + - Each spawned with their lens prompt via `--append-system-prompt-file` (or inline for built-ins) + - Default model per member (configurable at spawn) +3. Update `/council` skill (SKILL.md) for dual-mode awareness + - **Lightweight mode:** When run directly in a session, behaves as today (simulated perspectives) + - **Full spawn mode:** When council members are hired in team, skill detects them and posts topic to team chat instead of simulating. Reads responses from chat. Leader makes final call. +4. Remove old `genie council` command from `src/genie.ts` (replaced by team hire + skill) + +**Acceptance criteria:** +- `genie team hire council` adds all 10 council members to team +- Each council member spawns with correct lens prompt and default model +- `/council` in lightweight mode works as before (simulated, single session) +- `/council` in full spawn mode posts to team chat, council members respond independently +- Old `genie council` command no longer exists + +**Validation:** +```bash +bun run typecheck +bun test src/lib/builtin-agents.test.ts +``` + +**depends-on:** Group 1, Group 3, Group 6 + +--- + +### Group 9: Skill Prompt Refinement + +**Goal:** Update 10 skill prompts to align with new orchestration model using `/refine`. + +**Deliverables:** +Each skill gets a `/refine` pass to update: + +1. **brainstorm** — Multi-agent aware. Reads/writes shared worktree `.genie/`. Acknowledges injected context from dispatch. Handles concurrent editors. +2. **wish** — Collaborative. Creates wish file + state group definitions in shared worktree. Back-and-forth via messaging. +3. **work** — Does NOT manage state. Receives group context from dispatch. Signals completion to leader via `genie send`. Uses `genie spawn` for subagent dispatch. No `bd close`, no checkbox updates. +4. **review** — Receives scope from dispatch. Council can participate via team chat. Uses `genie spawn` for dispatch. +5. **fix** — Uses `genie spawn` for fixer/reviewer dispatch. +6. **dream** — Uses new team/worktree model. Creates teams per wish. Uses `genie work` for dispatch. State machine for tracking. +7. **council** — Dual-mode awareness (lightweight skill vs full spawn team). +8. **trace** — Uses `genie spawn` for dispatch. +9. **onboarding** — Update for new directory model, team model, session naming. +10. **docs** — Uses `genie spawn` for dispatch. + +**Cross-cutting changes in all refined skills:** +- Dispatch method: `genie spawn ` replaces `Task tool` / `genie agent spawn --role` +- File paths: `.genie/` in shared worktree, not repo root +- State management: skills do not manage state — transitions via `genie work`/`genie done` +- Context injection: skills acknowledge injected context (file path + extracted section) +- Role separation preserved: never combine implementor+reviewer, fixer+reviewer, tracer+fixer + +**Acceptance criteria:** +- All 10 skill SKILL.md files updated +- No skill references `genie agent spawn`, `Task tool`, `bd`, or beads +- `/work` skill has zero state management logic (no checkboxes, no status writes) +- `/brainstorm` and `/wish` reference shared worktree paths +- `/council` skill documents both modes + +**Validation:** +```bash +grep -rn 'genie agent spawn\|Task tool\|\bbd\b\|beads' skills/ && echo "FAIL: old patterns" || echo "PASS" +grep -rn 'checkbox\|Status.*SHIPPED\|bd close' skills/work/ && echo "FAIL: state mgmt in /work" || echo "PASS" +``` + +**depends-on:** Group 5, Group 7, Group 8 + +--- + +### Group 10: Final Validation & Integration + +**Goal:** Full quality gates pass with all changes integrated. + +**Deliverables:** +1. Run full check suite (`bun run check`) +2. Run build (`bun run build`) +3. Verify no stale references remain (beads, tui, old commands, old patterns) +4. End-to-end smoke test: register agent → create team → hire → dispatch work → done → status +5. Open GitHub issue: "Monitor need for sub-group task granularity" + +**Acceptance criteria:** +- `bun run check` exits 0 +- `bun run build` succeeds +- No beads/bd references in src/ +- No `genie agent` namespace in src/ +- No `persistSystemPrompt` or `$(cat)` pattern in src/ +- No profiles/blueprints code in src/ +- E2E flow works: dir add → team create → team hire → genie work → genie done → genie status + +**Validation:** +```bash +bun run check +bun run build +grep -rn 'persistSystemPrompt\|\\$\\(cat\|beads\|\bbd\b' src/ --include='*.ts' && echo "FAIL" || echo "PASS" +grep -rn "command('agent')\|command('task')\|command('profiles')" src/ --include='*.ts' && echo "FAIL" || echo "PASS" +``` + +**depends-on:** Group 7, Group 8, Group 9 + +--- + +## Dependency Graph + +``` +Group 1 (Directory) Group 4 (State Machine) + │ │ + ├──→ Group 2 (Spawn) ──────┤ + │ │ │ + │ ├──→ Group 5 (Dispatch) ──→ Group 9 (Skills) + │ │ │ + ├──→ Group 3 (Teams) ──→ Group 6 (Messaging) │ + │ │ │ │ + │ ├──→ Group 8 (Council) │ + │ │ │ │ + ├─────────┴──→ Group 7 (Promotion) ─────────┤ + │ │ + └───────────────────────────────→ Group 10 (Validation) +``` + +Parallelizable: Group 1 + Group 4 can start simultaneously. +Group 2 + Group 3 can start once Group 1 is done. +Group 5 can start once Group 2 + Group 4 are done. +Group 6 can start once Group 1 + Group 2 + Group 3 are done. +Group 7 can start once Group 2 + Group 3 are done (cleanup distributed to earlier groups). +Group 8 can start once Group 1 + Group 3 + Group 6 are done. + +## Assumptions / Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| `--system-prompt-file` undocumented, could break in Claude Code update | Medium | Test in CI. Confirmed working today. Fallback: `--system-prompt "$(cat)"` | +| State file abandonment (agents don't run `genie done`) | High | State transitions are genie commands only. Orchestrator tracks at prompt level | +| Concurrent worktree edits from multiple agents | Medium | Git handles conflicts. Wish groups scoped to non-overlapping files | +| 10 skill prompts need /refine — high effort | Medium | Prioritize core chain (brainstorm→wish→work→review). Others incremental | +| Council full spawn = 10 Claude sessions = cost | Low | Leader chooses subset via smart routing. Lightweight mode for cheap reviews | +| Multi-team agent receives message — which context? | Medium | Send scoped to own team. Agent receives team context with dispatch | +| Removing 25+ commands breaks existing agent workflows | Medium | Clean break is decided. No backward compat. Update all agent AGENTS.md | +| beads removal leaves no task tracking for external integrations | Low | State file is the replacement. Issue opened for sub-group granularity if needed | diff --git a/.gitignore b/.gitignore index 4ac227da1..906c56ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ yarn-error.log* # Worker worktrees .genie/worktrees/ +# Claude Code worktrees +.claude/worktrees/ + # Worker registry (contains session IDs) .genie/workers.json diff --git a/README.md b/README.md index 8a7aa22de..003427115 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Identity, skills, memory — markdown files you own. Git-versioned. | One Claude tab per task, alt-tab between 5 of them | Parallel agents in live terminal sessions | | Eyeball generated code, miss a bug, fix at 2am | Automated `/review` with severity-tagged gaps | | 45 min in, Claude forgets your instructions | Scoped specialists — no context window accumulates junk | -| 10 min of setup before any work starts | `genie work bd-42` — inherits context automatically | +| 10 min of setup before any work starts | `genie work wish-42` — inherits context automatically |
## The Wish Pipeline @@ -141,26 +141,26 @@ Identity, skills, memory — markdown files you own. Git-versioned. |---------|-------------| | `genie` | Launch a session in the current directory | | `genie work ` | Work on a specific task | -| `genie council ` | Run council review on a topic | +| `/council` | Run council review on a topic (skill, not CLI command) | | `genie send ` | Send a message to an agent | | `genie inbox` | View pending messages and approvals | | `genie daemon` | Start background daemon | -**Agent management (`genie agent`):** +**Agent management:** | Command | Description | |---------|-------------| -| `genie agent spawn` | Spawn a new agent | -| `genie agent list` | List running agents | -| `genie agent dashboard` | Live agent dashboard | -| `genie agent approve ` | Approve agent action | -| `genie agent answer ` | Answer agent question | -| `genie agent history ` | View agent history | -| `genie agent events ` | Stream agent events | -| `genie agent close ` | Close an agent session | -| `genie agent ship ` | Ship agent work (create PR) | -| `genie agent kill ` | Force-stop an agent | -| `genie agent suspend ` | Suspend agent execution | +| `genie spawn` | Spawn a new agent | +| `genie ls` | List running agents | +| `genie dashboard` | Live agent dashboard | +| `genie approve ` | Approve agent action | +| `genie answer ` | Answer agent question | +| `genie history ` | View agent history | +| `genie events ` | Stream agent events | +| `genie close ` | Close an agent session | +| `genie ship ` | Ship agent work (create PR) | +| `genie kill ` | Force-stop an agent | +| `genie suspend ` | Suspend agent execution | **Team management (`genie team`):** diff --git a/docs/CO-ORCHESTRATION-GUIDE.md b/docs/CO-ORCHESTRATION-GUIDE.md index b0f7d387b..8845c6432 100644 --- a/docs/CO-ORCHESTRATION-GUIDE.md +++ b/docs/CO-ORCHESTRATION-GUIDE.md @@ -1,83 +1,81 @@ # Co-Orchestration Guide: AI-Human Software Development -This guide explains how to use genie-cli's worker orchestration system for collaborative software development between humans and AI agents. +This guide explains how to use genie's worker orchestration system for collaborative software development between humans and AI agents. ## Overview -The system enables multiple Claude agents to work on different tasks simultaneously, each in isolated git worktrees, while a human orchestrates and reviews their work. All state is tracked in beads for unified visibility. +The system enables multiple Claude agents to work on different tasks simultaneously, each in isolated git worktrees, while a human orchestrates and reviews their work. All state is tracked through the wish pipeline for unified visibility. ``` Human (Orchestrator) │ - ├── genie work bd-1 ──▶ Worker 1 (Claude in pane %1) - │ └── worktree: .worktrees/bd-1/ + ├── genie work wish-1 ──▶ Worker 1 (Claude in pane %1) + │ └── worktree: .worktrees/wish-1/ │ - ├── genie work bd-2 ──▶ Worker 2 (Claude in pane %2) - │ └── worktree: .worktrees/bd-2/ + ├── genie work wish-2 ──▶ Worker 2 (Claude in pane %2) + │ └── worktree: .worktrees/wish-2/ │ - └── genie agent list ──▶ Status dashboard + └── genie ls ──▶ Status dashboard ``` ## Prerequisites 1. **tmux session**: You must be in a tmux session -2. **beads initialized**: Run `bd init` in your repo if not already done +2. **genie installed**: Run `genie setup` in your repo if not already done 3. **Claude CLI**: The `claude` command must be available ## Quick Start ```bash -# 1. Start the beads daemon for auto-sync +# 1. Start the genie daemon for auto-sync genie daemon start -# 2. Create issues to work on -bd create "Implement user authentication" -bd create "Add unit tests for auth module" -bd create "Update API documentation" +# 2. Create wishes to work on (via the /wish skill in a genie session) +# /wish "Implement user authentication" +# /wish "Add unit tests for auth module" +# /wish "Update API documentation" -# 3. Start a worker on the first issue -genie work bd-1 +# 3. Start a worker on the first wish +genie work wish-1 # 4. Check worker status -genie agent list +genie ls # 5. When worker needs approval -genie agent approve +genie approve -# 6. When done, close the issue -genie agent close bd-1 +# 6. When done, close the wish +genie close wish-1 ``` ## Detailed Workflow -### Phase 1: Planning & Issue Creation +### Phase 1: Planning & Wish Creation -Before spawning workers, create well-defined issues in beads: +Before spawning workers, create well-defined wishes using the `/wish` skill: ```bash -# Create issues with clear titles -bd create "Add login endpoint with JWT tokens" -bd create "Create user registration form" -bd create "Write integration tests for auth flow" - -# Set dependencies if needed -bd update bd-2 --blocked-by bd-1 -bd update bd-3 --blocked-by bd-1,bd-2 - -# View the queue -bd ready # Shows issues ready to work on -bd list # Shows all issues with status +# Launch a genie session +genie + +# Inside the session, use the /wish skill to create wishes: +# /wish "Add login endpoint with JWT tokens" +# /wish "Create user registration form" +# /wish "Write integration tests for auth flow" + +# Wishes are managed through the wish state machine: +# brainstorm → wish → plan → work → review → ship ``` ### Phase 2: Spawning Workers -Start workers for ready issues: +Start workers for ready wishes: ```bash -# Work on a specific issue -genie work bd-1 +# Work on a specific wish +genie work wish-1 -# Or let the system pick the next ready issue +# Or let the system pick the next ready wish genie work next # Options: @@ -86,23 +84,22 @@ genie work next # --prompt Custom initial prompt ``` -**What happens when you run `genie work bd-1`:** +**What happens when you run `genie work wish-1`:** 1. Daemon starts (if not running) for auto-sync -2. Issue is claimed (status → in_progress) -3. Worktree created via `bd worktree create bd-1` +2. Wish is claimed (status → in_progress) +3. Worktree created for the wish 4. New tmux pane spawned in the worktree directory 5. Claude CLI launched with initial prompt -6. Agent bead created to track the worker -7. Work bound to agent via slot system +6. Work bound to agent via slot system ### Phase 3: Monitoring Workers ```bash # Check all workers -genie agent list +genie ls # JSON output for scripting -genie agent list --json +genie ls --json ``` **Worker States:** @@ -119,51 +116,49 @@ genie agent list --json **Approve permissions:** ```bash -genie agent approve # Approve pending permission -genie agent approve --start # Start auto-approve engine +genie approve # Approve pending permission +genie approve --start # Start auto-approve engine ``` **Answer questions:** ```bash -genie agent answer bd-1 1 # Select option 1 -genie agent answer bd-1 "text:custom" # Provide custom text answer +genie answer wish-1 1 # Select option 1 +genie answer wish-1 "text:custom" # Provide custom text answer ``` -### Phase 5: Closing Issues +### Phase 5: Closing Wishes When a worker completes its task: ```bash -# Close issue and cleanup worker -genie agent close bd-1 +# Close wish and cleanup worker +genie close wish-1 # Options: # --merge Merge worktree branch to main before cleanup # --keep-worktree Don't delete the worktree -# --no-sync Skip bd sync # -y, --yes Skip confirmation ``` **What happens:** -1. Issue closed in beads (status → done) -2. Beads synced to git +1. Wish closed (status → done) +2. State synced 3. Worktree removed (unless --keep-worktree) 4. Worker pane killed -5. Agent bead deleted ### Phase 6: Force Killing Workers If a worker is stuck or needs to be terminated: ```bash -genie agent kill bd-1 +genie kill wish-1 ``` -Note: This does NOT close the issue. The task remains `in_progress` in beads. +Note: This does NOT close the wish. The task remains `in_progress`. ## Daemon Management -The beads daemon auto-commits and syncs changes: +The daemon auto-commits and syncs changes: ```bash genie daemon start # Start with auto-commit @@ -181,105 +176,80 @@ genie daemon restart # Restart with fresh config ### Pattern 1: Sequential Dependencies ```bash -# Create dependent tasks -bd create "Design database schema" # bd-1 -bd create "Implement models" # bd-2 -bd update bd-2 --blocked-by bd-1 - -# Start first task -genie work bd-1 +# Create dependent wishes via /wish skill, then work sequentially +genie work wish-1 -# When bd-1 completes, bd-2 becomes ready -genie agent close bd-1 -genie work next # Picks bd-2 +# When wish-1 completes, wish-2 becomes ready +genie close wish-1 +genie work next # Picks wish-2 ``` ### Pattern 2: Parallel Independent Tasks ```bash -# Create independent tasks -bd create "Add user profile page" -bd create "Add settings page" -bd create "Add notifications page" - -# Spawn multiple workers -genie work bd-1 -genie work bd-2 -genie work bd-3 +# Create independent wishes, then spawn multiple workers +genie work wish-1 +genie work wish-2 +genie work wish-3 # Monitor all -genie agent list +genie ls ``` ### Pattern 3: Review and Iterate ```bash # Worker completes, but needs revision -# Don't close yet — reopen and reassign -bd update bd-1 --status open -genie work bd-1 +# Reopen and reassign +genie work wish-1 ``` -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `TERM_USE_BEADS_REGISTRY` | `true` | Set to `false` to use JSON registry fallback | - ## Troubleshooting ### Worker shows as dead but pane exists ```bash # The registry may be out of sync -genie agent kill # Clean up registry entry -genie work # Start fresh +genie kill # Clean up registry entry +genie work # Start fresh ``` ### Worktree creation fails ```bash # Check if branch already exists -git branch -a | grep +git branch -a | grep # Remove orphaned worktree -git worktree remove .worktrees/ --force -``` - -### Daemon won't start -```bash -# Check bd daemon directly -bd daemon status -bd daemon start --auto-commit +git worktree remove .worktrees/ --force ``` ## Best Practices -1. **Clear issue titles**: Workers use titles as context -2. **One task per worker**: Keep issues focused -3. **Use dependencies**: `--blocked-by` prevents premature work -4. **Review before closing**: Check worker output before `genie agent close` +1. **Clear wish titles**: Workers use titles as context +2. **One task per worker**: Keep wishes focused +3. **Use the wish pipeline**: `/brainstorm` → `/wish` → `/work` → `/review` → ship +4. **Review before closing**: Check worker output before `genie close` 5. **Use worktrees**: They provide isolation and can be reviewed independently -6. **Keep daemon running**: Ensures beads state is synced to git +6. **Keep daemon running**: Ensures state is synced to git ## Command Reference | Command | Description | |---------|-------------| -| `genie work ` | Spawn worker for issue | -| `genie work next` | Work on next ready issue | -| `genie agent list` | List all workers | -| `genie agent approve` | Approve permission / manage auto-approve | -| `genie agent answer ` | Answer question | -| `genie agent history ` | Compressed session catch-up | -| `genie agent events [pane-id]` | Stream Claude Code events | -| `genie agent close ` | Close issue and cleanup | -| `genie agent ship ` | Mark done, merge, cleanup | -| `genie agent kill ` | Force kill worker | -| `genie agent read ` | Read worker pane output | -| `genie agent exec ` | Execute command in worker pane | -| `genie daemon start` | Start beads daemon | -| `genie daemon stop` | Stop beads daemon | +| `genie work ` | Spawn worker for wish | +| `genie work next` | Work on next ready wish | +| `genie ls` | List all workers | +| `genie approve` | Approve permission / manage auto-approve | +| `genie answer ` | Answer question | +| `genie history ` | Compressed session catch-up | +| `genie events [pane-id]` | Stream Claude Code events | +| `genie close ` | Close wish and cleanup | +| `genie ship ` | Mark done, merge, cleanup | +| `genie kill ` | Force kill worker | +| `genie read ` | Read worker pane output | +| `genie exec ` | Execute command in worker pane | +| `genie daemon start` | Start daemon | +| `genie daemon stop` | Stop daemon | | `genie daemon status` | Show daemon status | -| `genie council` | Spawn dual-model deliberation | ## Architecture @@ -295,19 +265,19 @@ bd daemon start --auto-commit │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ .worktrees/ │ │ .worktrees/ │ │ .worktrees/ │ │ -│ │ bd-1/ │ │ bd-2/ │ │ bd-3/ │ │ +│ │ wish-1/ │ │ wish-2/ │ │ wish-3/ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └────────────────┼────────────────┘ │ │ ▼ │ │ ┌─────────────┐ │ │ │ .genie/ │ ◀── Shared via redirect │ -│ │ issues.jsonl│ │ +│ │ wishes/ │ │ │ └──────┬──────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ -│ │ bd daemon │ ◀── Auto-commit & sync │ +│ │ genie daemon│ ◀── Auto-commit & sync │ │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -316,7 +286,7 @@ bd daemon start --auto-commit After reading this guide: 1. Start with `genie daemon start` -2. Create a few test issues with `bd create` +2. Launch a session with `genie` and create wishes via `/wish` 3. Try `genie work ` to spawn your first worker 4. Practice the workflow with simple tasks 5. Scale up to multi-worker orchestration diff --git a/docs/agent-profiles.md b/docs/agent-profiles.md index 4165d33a7..c09c315a7 100644 --- a/docs/agent-profiles.md +++ b/docs/agent-profiles.md @@ -47,10 +47,10 @@ genie profiles default coding-fast ## Using Profiles -### With genie agent spawn +### With genie spawn ```bash -genie agent spawn --role implementor --profile coding-fast -genie agent spawn --role implementor --profile safe # For interactive work +genie spawn --role implementor --profile coding-fast +genie spawn --role implementor --profile safe # For interactive work ``` ### With genie work diff --git a/knip.json b/knip.json index a7a30f6d2..3e3315bd0 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,7 @@ { "$schema": "https://unpkg.com/knip@5.85.0/schema.json", "project": ["src/**/*.ts"], - "ignoreBinaries": ["tmux", "bd", "which"] + "ignoreBinaries": ["tmux", "which"], + "ignore": ["src/lib/team-auto-spawn.ts"], + "ignoreExportsUsedInFile": true } diff --git a/report.md b/report.md new file mode 100644 index 000000000..10838eecc --- /dev/null +++ b/report.md @@ -0,0 +1,171 @@ +# Claude Code framework landscape: where genie fits and how to win + +**Genie enters a crowded, fast-moving market dominated by obra/superpowers (~75k stars) with eight other major frameworks competing across four distinct niches.** The good news: most competitors over-engineer their solutions, inflate feature counts, or lock users into Claude Code exclusively — creating clear openings for genie's "simpler and more effective" positioning. The bad news: genie's 250 stars place it far behind in visibility, and the window to establish dominance is narrowing as the ecosystem matures rapidly. This report profiles all nine target competitors plus the most important discovery from broader search, then delivers actionable strategy for genie to win. + +--- + +## The top 10 frameworks ranked by GitHub stars + +The competitive landscape stratifies into clear tiers. Star counts for some repos warrant skepticism — several show suspicious ratios of stars to actual community engagement. + +| Rank | Repository | Stars | Forks | Commits | License | Backing | Last Active | Category | +|------|-----------|-------|-------|---------|---------|---------|-------------|----------| +| 1 | **obra/superpowers** | ~75,000 | 5,800 | Active | MIT | Individual (Jesse Vincent) | Mar 2026 | Skills framework | +| 2 | **bmad-code-org/BMAD-METHOD** | ~35,400 | 4,400 | 1,350 | MIT + Trademark | LLC (BMad Code) | Mar 2026 | Agile SDLC framework | +| 3 | **wshobson/agents** ⚠️ | ~28,000 | 3,100 | 224 | MIT | Individual | Feb 2026 | Plugin catalog (markdown only) | +| 4 | **gsd-build/get-shit-done** | ~27,000 | 2,300 | 757 | MIT | Individual | Mar 2026 | Context engineering | +| 5 | **eyaltoledano/claude-task-master** | ~25,700 | 2,400 | 1,212 | MIT + Commons Clause | Individual | Mar 2026 | Task management | +| 6 | **SuperClaude-Org/SuperClaude** | ~21,300 | 1,800 | 341 | MIT | Community/Individual | Feb 2026 | Behavioral config | +| 7 | **ruvnet/ruflo** ⚠️ | ~20,200 | 2,200 | 5,800 | MIT | Individual | Mar 2026 | Swarm orchestration | +| 8 | **Yeachan-Heo/oh-my-claudecode** | ~8,500 | 589 | 380 | MIT | Individual | Mar 2026 | Multi-agent orchestration | +| 9 | **ComposioHQ/agent-orchestrator** | 2,700 | 274 | 131 | MIT | Company (Composio) | Feb 2026 | Fleet management | +| 10 | **automagik-dev/genie** | 250 | 34 | 2,022 | MIT | Company (Namastex Labs) | Mar 2026 | Markdown-native agents | + +The ⚠️ flags on wshobson/agents and ruvnet/ruflo reflect credibility concerns: agents has **28k stars on a 224-commit markdown-only repo with just 7 open issues**, while ruflo shows multiple user reports of the product not working as documented and suspiciously high star-to-contributor ratios across all of ruvnet's repos. + +--- + +## Detailed comparison across all dimensions + +| Dimension | superpowers | BMAD | agents | GSD | Task Master | SuperClaude | ruflo | OMC | agent-orchestrator | **genie** | +|-----------|------------|------|--------|-----|-------------|-------------|-------|-----|-------------------|-----------| +| **README Quality** | 9/10 | 8/10 | 8/10 | 9/10 | 8/10 | 6/10 | 5/10 | 7/10 | 9/10 | 8/10 | +| **Codebase Maturity** | High | High | None (MD only) | Medium | High | Medium | Questionable | Medium | Early | Medium-High | +| **Real Code?** | Yes | Yes | No | Yes | Yes | Partial | Yes | Yes | Yes | Yes | +| **Provider-Agnostic** | Partial | Yes (10+) | No | Partial | Yes (13+ IDEs) | No | No | No | Yes | Yes | +| **Data Ownership** | Partial | Partial | N/A | Yes | Yes | No | No | No | No | **Yes (core)** | +| **Multi-Agent** | Yes (subagents) | Yes (Party Mode) | Config only | Yes | No | No | Yes (swarm) | Yes | Yes (fleet) | Yes | +| **Memory/Learning** | No | No | No | No | No | No | Claimed | No | No | **Yes (/brain, /learn)** | +| **TUI/Dashboard** | No | No | No | No | No | No | Partial | HUD | Yes (web) | **Yes (TUI)** | +| **Target** | All devs | Enterprise→Solo | Claude users | Solo devs | AI IDE users | Claude users | Claude users | Claude users | Teams | All devs | +| **Install Friction** | Low (plugin) | Low (npx) | Low (plugin) | Low (npx) | Low (npm) | Medium | Medium | Low (plugin) | High (build) | Low (npm) | + +--- + +## Competitor profiles + +### obra/superpowers — The 800-pound gorilla nobody can ignore + +Superpowers is the undisputed leader at **~75,000 stars**, listed in Claude Code's official plugin marketplace, and credited by GitHub Next as inspiration for their Agentic Workflows project. Created by Jesse Vincent, it takes a composable "skills" approach: **20+ battle-tested skills** that auto-activate for specific tasks like brainstorming, TDD, plan writing, code review, and subagent-driven development. The `/brainstorm → /write-plan → /execute-plan` workflow is clean and proven. It also supports Codex and OpenCode, making it partially cross-platform. + +The README is excellent — clear, professional, and focused on workflows rather than feature counts. Its main weakness is the **lack of persistent memory** between sessions. It also doesn't offer a full TUI or daemon mode. The ecosystem around it (superpowers-lab, superpowers-marketplace, superpowers-chrome, superpowers-ccg) shows maturity but also fragmentation. Genie's advantages: persistent knowledge vault (/brain), behavioral learning (/learn), full TUI cockpit, daemon mode, and genuine vendor independence. What superpowers has that genie doesn't: massive adoption, official marketplace listing, and the network effects that come with 75k stars. + +### BMAD-METHOD — Enterprise agile dressed in open-source clothing + +The most professionally organized framework at **~35,400 stars**, backed by BMad Code LLC with registered trademarks. BMAD brings traditional agile methodology to AI coding: PRDs, epics, stories, sprints, architecture reviews — the full SDLC. It offers **21 specialized agents, 50+ guided workflows**, and a module ecosystem (BMM core, BMad Builder, TEA, BMGD, CIS). It supports 10+ platforms including Claude Code, Cursor, Codex, Gemini, and Windsurf. + +The README is comprehensive but dense, leaning heavily on enterprise terminology that may alienate solo developers. The v6 beta creates version confusion alongside the stable v4. BMAD's key strength is **scale-adaptiveness** — it adjusts from bug fixes to enterprise platforms. Its weakness is complexity: five modules, dozens of agents, and agile jargon everywhere. The documentation site (docs.bmad-method.org) follows Diataxis principles, showing real investment in developer education. Genie's edge: dramatically simpler workflow (wish pipeline vs. PRD/epic/sprint ceremony), data ownership philosophy, and the "/brain" knowledge system. What BMAD has: broader platform support, deeper enterprise methodology, and 140× more stars. + +### wshobson/agents — Massive star count, zero executable code + +At **~28,000 stars**, this repo is misleading: it contains **no actual code**. The "112 agents" and "73 plugins" are markdown persona files and configuration templates for Claude Code's plugin marketplace. The 224 total commits and only 7 open issues against 28k stars represent the most suspicious engagement ratio in this entire landscape. + +The README is well-organized with practical quick-start instructions and a clear progressive-disclosure architecture (metadata → instructions → resources). The plugin categorization across 24 categories is genuinely useful as a catalog. But calling markdown templates an "agent framework" overpromises significantly. Genie's advantage is fundamental: **genie is real software** with TypeScript, a TUI, daemon mode, and actual agent lifecycle management. What agents has: a massive catalog of pre-built persona definitions and the simplicity of "just markdown." + +### gsd-build/get-shit-done — Anti-enterprise insurgent with real traction + +GSD at **~27,000 stars** has the most distinctive voice in the space — profane, contrarian, and explicitly positioned against BMAD and other "enterprise roleplay." Created by solo developer "glittercowboy," it solves context rot through spec-driven development with fresh context windows per execution plan. The `/gsd:discuss-phase → /gsd:plan-phase → /gsd:execute-phase → /gsd:verify-work` pipeline is effective and well-documented. + +The README is compelling storytelling: "I'm a solo developer. I don't write code." Installation is a one-liner npx command. The six-step workflow walkthrough with exact commands and file outputs is one of the best onboarding experiences in the space. Key weaknesses: the **$GSD crypto token on Solana** raises serious credibility concerns, the edgy language alienates enterprise users, and **136 open issues** (many tagged "needs-triage") suggest a maintenance backlog. Genie's advantages: company backing (more sustainable), knowledge persistence (/brain vs. no memory), approval gates for human oversight, and no crypto baggage. GSD's edge: dramatically better marketing narrative, user testimonials from Amazon/Google/Shopify engineers, and 100× more stars. + +### eyaltoledano/claude-task-master — The AI project manager everyone knows + +Task Master at **25,700 stars** is the most widely recognized tool in this category, going from zero to 15.5k stars in nine weeks. It parses PRDs into structured tasks with dependencies, priorities, and subtasks. It supports **13+ IDEs** (Cursor, Claude Code, Windsurf, VS Code, Kiro, Zed) and 36 MCP tools across multiple LLM providers (Anthropic, OpenAI, Google, Perplexity, Ollama). + +The README is professional with NPM download badges, CI status, and clear dual installation paths (MCP + CLI). The MIT + Commons Clause license restricts reselling, which is notable. Its core strength is **editor-agnostic task management** — it works everywhere, not just in Claude Code. Weaknesses: no multi-agent orchestration, no persistent memory, no TUI, and the README is very long. Genie's advantages: multi-agent orchestration with the wish pipeline, persistent /brain knowledge, purpose-driven ephemeral agents, and TUI cockpit. Task Master's edge: broader IDE support, PRD decomposition maturity, and established npm presence with high download counts. + +### SuperClaude Framework — Behavioral injection with international appeal + +SuperClaude at **21,300 stars** is a behavioral configuration layer that enhances Claude Code through markdown instruction files. It offers **30 slash commands, 16 specialist agents, 7 behavioral modes**, and the Wave→Checkpoint→Wave parallel execution pattern (claiming 3.5x speed improvement). Multi-language README support (Japanese, Korean, Chinese) gives it strong international reach. + +The README is emoji-heavy and somewhat informal — the donation appeal for the maintainer's Claude Max subscription highlights its community-project nature. The concept of "behavioral configuration" versus actual software may confuse newcomers. It's **Claude Code-exclusive**, limiting its addressable market. The sister projects (SuperGemini, SuperQwen) show ambition but fragment effort. Genie's advantages: real executable software vs. behavioral configs, provider independence, persistent knowledge, company backing. SuperClaude's edge: international community, simpler mental model (just "make Claude behave better"), and deep Claude Code integration. + +### ruvnet/ruflo — Ambitious claims, questionable execution + +Ruflo at **~20,200 stars** claims to be "the leading agent orchestration platform" with **60+ agents, 175+ MCP tools**, self-learning neural capabilities, WASM/Rust kernels, and swarm intelligence. The feature list is staggering — GOAP goal planning with A* pathfinding, truth verification systems, and vector graph neural networks. + +However, **multiple GitHub issues (#624, #958) report users unable to get basic functionality working**. Feature counts appear inflated — numbers vary across different pages (60 agents vs. 64, 87 tools vs. 175+). The README is overwhelmingly long and reads more like a marketing deck than developer documentation. The star-to-contributor ratio across all ruvnet repos warrants skepticism. With **427 open issues** — the highest of any competitor — maintenance capacity is questionable. Genie's advantage: genie actually works. Its honest README, working CLI, and real engineering (2,022 commits) stand in stark contrast. What ruflo claims to offer: self-learning capabilities and Rust/WASM performance, if they function as documented. + +### Yeachan-Heo/oh-my-claudecode — Power modes for Claude Code maximalists + +OMC at **~8,500 stars** offers five execution modes (Autopilot, Ultrapilot, Swarm, Pipeline, Ecomode) with **32 specialized agents** and smart model routing that claims 30-50% token savings. "Ralph Mode" provides persistent execution that won't stop until architect-verified complete. The installation via Claude Code's plugin marketplace is the simplest in the space — two commands. + +The README's aggressive tone ("A weapon, not a tool") and grammatical issues ("Your Claude Just Have been Steroided") hurt professionalism. But the **131+ releases and 817 closed PRs** show serious development velocity. It's Claude Code-exclusive, limiting reach. The magic keywords system (/deep-interview, /council, /swarm) provides intuitive shortcuts. Genie's advantages: provider independence, persistent /brain knowledge, data ownership, company backing. OMC's edge: simpler installation via plugin marketplace, more execution modes, and token-saving model routing. + +### ComposioHQ/agent-orchestrator — Company-backed fleet management + +Agent-orchestrator at **2,700 stars** is the only framework explicitly designed for **managing fleets of 30+ parallel AI agents**. Backed by Composio (a funded company), it provides runtime-agnostic (tmux/Docker/k8s), agent-agnostic (Claude Code/Codex/Aider), and tracker-agnostic (GitHub/Linear) orchestration. The plugin architecture with 8 swappable slots (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Lifecycle) is the most extensible design in the space. + +The README is the most professionally written of all competitors — clear problem/solution framing ("Running one AI agent is easy. Running 30 is a coordination problem"). But it's the least mature: only 131 commits, must build from source (no npm), and 67 open PRs suggest a review bottleneck. Claims of **84.6% CI success rate** across self-correcting agent branches are compelling but early. Genie's advantages: working npm distribution, TUI, persistent knowledge, simpler workflow. Agent-orchestrator's edge: true fleet-scale parallel execution, corporate backing from Composio, and the plugin architecture's extensibility. + +--- + +## How genie compares head-to-head against each competitor + +### What genie has that no competitor offers + +Three capabilities set genie apart across the entire landscape: + +- **Persistent knowledge vault (/brain):** An Obsidian-style note system where agents search before answering and write back intelligence. No other framework builds compounding knowledge over time. Claude-mem offers session memory, but not project-level knowledge graphs. +- **Behavioral learning (/learn):** Interactive mode that explores codebases, asks questions, and builds learning plans with approval gates. Zero competitors offer this. +- **True data portability:** All context lives in local markdown files — portable, transparent, and never locked in a vendor database. While GSD and Task Master use local files, neither makes data ownership a core philosophy the way genie does. +- **Purpose-driven ephemeral agents:** Workers born for one task, execute obsessively, report back, and dissolve. This is architecturally cleaner than persistent agent pools. +- **10-specialist Council review:** Architect, Simplifier, Sentinel, Operator, Deployer, Ergonomist, Questioner, Tracer, Benchmarker, Measurer critique designs. No competitor offers structured multi-perspective review. + +### Specific head-to-head gaps + +| Competitor | What genie has, they don't | What they have, genie doesn't | +|------------|---------------------------|-------------------------------| +| **superpowers** | /brain knowledge vault, /learn behavioral learning, TUI cockpit, daemon mode, Council review | 75k stars, official marketplace listing, 20+ battle-tested skills, massive ecosystem | +| **BMAD** | Simpler workflow, data ownership, /brain, /learn, ephemeral agents | 10+ platform support, enterprise agile methodology, documentation site, LLC structure | +| **agents** | Actual executable software, agent lifecycle management, TUI, daemon | Massive plugin catalog (73 plugins, 112 agent templates), plugin marketplace presence | +| **GSD** | /brain persistence, /learn, Council review, company backing, no crypto baggage | 100× more stars, compelling narrative marketing, user testimonials, discuss-phase | +| **Task Master** | Multi-agent orchestration, /brain, /learn, TUI, Council | 13+ IDE support, PRD decomposition, high npm downloads, Perplexity research integration | +| **SuperClaude** | Real software (not just configs), provider independence, /brain, /learn | International community (JP/KR/CN), 30 slash commands, deep Claude Code behavioral tuning | +| **ruflo** | Actually works reliably, honest README, real engineering | Self-learning claims (SAFLA), Rust/WASM components, swarm topologies (if functional) | +| **OMC** | Provider independence, /brain, /learn, Council, data ownership | Plugin marketplace installation, 5 execution modes, model routing (30-50% token savings) | +| **agent-orchestrator** | Working npm distribution, TUI, /brain, /learn, simpler UX | Fleet-scale orchestration (30+ agents), plugin architecture (8 slots), web dashboard | + +--- + +## Strategic recommendations for genie to win + +### 1. Own the "honest framework" narrative + +The single most powerful positioning move genie can make is to **lean into authenticity in a space plagued by inflated metrics**. Ruflo claims 175+ tools that users report don't work. Agents has 28k stars on markdown files. GSD has a crypto token. BMAD requires enterprise ceremony. Genie should explicitly position as "the framework that does what it says" — highlight the 2,022 real commits, the working TUI, the actual TypeScript codebase. A comparison page showing commit-to-star ratios and actual functionality versus claims would be devastating to competitors. + +### 2. Get into Claude Code's plugin marketplace immediately + +Superpowers, OMC, and agents all distribute through Claude Code's native plugin marketplace. This is now table stakes for discovery. **Genie's absence from the marketplace is its single biggest distribution gap.** The plugin install experience (`/plugin marketplace add` → `/plugin install`) is dramatically lower friction than npm. Prioritize this above all other distribution work. + +### 3. Double down on /brain and /learn as the killer differentiator + +No competitor offers persistent, compounding project knowledge. This is genie's moat. The README should lead with this: "Your AI forgets everything between sessions. Genie doesn't." Build demo videos showing /brain in action — an agent searching project knowledge before answering, writing back discoveries, and another agent benefiting from that knowledge days later. This is the feature that makes "simpler and more effective" concrete and provable. + +### 4. Build a "genie vs. X" comparison page for each major competitor + +BMAD, GSD, Task Master, and superpowers all have enough stars that developers will evaluate them first. Genie needs **dedicated comparison pages** (like Notion vs. Confluence style) that honestly show feature-by-feature differences. The key narrative for each: +- vs. superpowers: "Superpowers gives you skills. Genie gives you a brain." +- vs. BMAD: "Skip the enterprise ceremony. Ship with wishes." +- vs. GSD: "Same simplicity, real persistence, no crypto." +- vs. Task Master: "Don't just manage tasks. Let agents learn your codebase." + +### 5. Fix the README's star-count gap with social proof and architecture clarity + +Genie's README has a distinctive voice (written as the Genie itself) which is memorable, but it undersells the technical depth. Add three things: **(1)** An architecture diagram showing the wish pipeline, daemon, TUI, /brain, and agent lifecycle — this would instantly differentiate from markdown-only competitors. **(2)** A "Who uses genie" section with real testimonials, even from 5-10 early adopters. **(3)** A 30-second GIF showing the TUI cockpit in action — no competitor has this, and it's visually compelling. + +### 6. Pursue cross-platform support aggressively + +Task Master supports 13+ IDEs. BMAD supports 10+ platforms. Genie is already provider-agnostic in philosophy, but the README should make this louder. Test and document explicit support for Cursor, Windsurf, VS Code Copilot, Codex CLI, and Gemini CLI. Each supported platform is a new discovery channel and a reason for developers locked into Claude Code-only tools to switch. + +### 7. Target the superpowers ecosystem gap + +Superpowers lacks persistent memory, a TUI, daemon mode, and behavioral learning. Its users are the most sophisticated Claude Code developers — exactly the audience that would value /brain and /learn. Consider building a **superpowers compatibility layer** or superpowers-to-genie migration guide. If genie can be framed as "superpowers + memory + orchestration," it immediately inherits credibility from the market leader. + +### 8. Launch a "context engineering" content strategy + +Coleam00's context-engineering-intro has **12,400 stars** as a methodology template — proof that educational content drives massive awareness. Genie should publish a "Context Engineering with Genie" guide that teaches the methodology while demonstrating genie's /brain, /learn, and Council features as the implementation. Blog posts, YouTube tutorials, and a landing page on context engineering would capture search traffic from developers discovering this space. + +## Conclusion + +The Claude Code framework market is large, noisy, and surprisingly shallow. Most competitors optimize for star counts and feature lists over working software and genuine developer experience. **Genie's 2,022 commits against 250 stars represent the highest engineering-to-visibility ratio in the entire space** — the product is undermarketed, not underbuilt. The /brain knowledge vault, /learn behavioral system, and Council review are genuine differentiators that no competitor matches. The strategic path forward is clear: get into the plugin marketplace for distribution, lead with /brain as the headline differentiator, build comparison content against each major competitor, and ride the "honest framework" narrative in a market where inflated claims are the norm. The window is still open — but with the space maturing this fast, the next 90 days matter more than the next year. diff --git a/skills/brainstorm/SKILL.md b/skills/brainstorm/SKILL.md index 8c743d053..b51f45270 100644 --- a/skills/brainstorm/SKILL.md +++ b/skills/brainstorm/SKILL.md @@ -12,9 +12,16 @@ Collaborate on fuzzy ideas until they are concrete enough for `/wish`. - Requirements are ambiguous and need interactive refinement - User explicitly invokes `/brainstorm` +## Context Injection + +This skill is multi-agent aware and operates on the shared worktree: +- All brainstorm artifacts live in `.genie/` within the shared worktree (not the repo root) +- When invoked via dispatch, acknowledges injected context (file path + extracted section) and uses it as seed +- Multiple agents can read brainstorm state concurrently; writes are coordinated by the orchestrator + ## Flow -1. **Read context:** scan current code, docs, conventions. Check `.genie/brainstorm.md` for an existing entry matching this slug/topic — use as seed if found. -2. **Init persistence:** create `.genie/brainstorms//DRAFT.md` immediately. Create `.genie/brainstorm.md` if missing (see Jar). +1. **Read context:** scan current code, docs, conventions. Check `.genie/brainstorm.md` in the shared worktree for an existing entry matching this slug/topic — use as seed if found. If context was injected from dispatch, use it directly. +2. **Init persistence:** create `.genie/brainstorms//DRAFT.md` immediately in the shared worktree. Create `.genie/brainstorm.md` if missing (see Jar). 3. **Clarify intent:** one question at a time, prefer multiple-choice. 4. **Show WRS bar** after every exchange (see WRS). 5. **Persist draft** when WRS changes OR every 2 minutes — whichever comes first. diff --git a/skills/council/SKILL.md b/skills/council/SKILL.md index 5bb1b464a..e80b26249 100644 --- a/skills/council/SKILL.md +++ b/skills/council/SKILL.md @@ -14,7 +14,19 @@ Convene a panel of 10 specialist perspectives to brainstorm, critique, and vote - During `/review` to surface risks and blind spots - Deadlocked discussions needing fresh angles -## Flow +## Mode Detection + +Before running the council flow, detect which mode to use: + +1. Run `genie team ls $GENIE_TEAM` (or the current team) and check if council members (names starting with `council-`) are present in the team. +2. **If council members are present** → use **Full Spawn Mode** +3. **If no council members** → use **Lightweight Mode** (default) + +## Lightweight Mode (Default) + +When no council members are hired in the team, simulate all perspectives in a single session. + +### Flow 1. Identify the topic from user context (architecture, performance, security, API design, operations, or general) 2. Route to the relevant council members (see Smart Routing below). Default: core trio @@ -23,6 +35,38 @@ Convene a panel of 10 specialist perspectives to brainstorm, critique, and vote 5. Synthesize a collective recommendation with the vote tally 6. Present the advisory and ask the user to decide +## Full Spawn Mode + +When council members are hired in the team, use the team chat channel for real multi-agent deliberation. + +### Flow + +1. Identify the topic and select relevant members (Smart Routing) +2. Post the topic to team chat: + ```bash + genie chat post --team "COUNCIL TOPIC: \n\nContext: \n\nPlease review and vote: APPROVE, REJECT, or MODIFY with rationale." + ``` +3. Notify each relevant council member via `genie send`: + ```bash + genie send 'New council topic posted to team chat. Read it, apply your lens, and post your perspective + vote.' --to council- + ``` +4. Wait for responses. Poll team chat for council member messages: + ```bash + genie chat read --team --since + ``` +5. Once all consulted members have responded (or after a reasonable wait), the leader synthesizes: + - Collect all perspectives from team chat + - Tally votes + - Produce the synthesized recommendation +6. Present the advisory to the user using the same output format + +### Notes on Full Spawn Mode + +- Council members respond independently — each applies their own lens prompt +- The leader (session running `/council`) acts as moderator and synthesizer +- If a council member hasn't responded, note them as "no response" in the tally +- Full spawn mode produces higher-quality reviews since each member runs in its own context + ## Council Members | Member | Focus | Lens | @@ -58,6 +102,7 @@ Convene a panel of 10 specialist perspectives to brainstorm, critique, and vote ## Council Advisory ### Topic: [Detected Topic] +### Mode: [Lightweight / Full Spawn] ### Members Consulted: [List] ### Perspectives diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index de7dde4b2..2dc1685df 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -22,13 +22,10 @@ Audit existing documentation, identify gaps, generate what's missing, and valida ## Dispatch -| Runtime | Detection | Pattern | -|---------|-----------|---------| -| Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | -| Codex | `CODEX_ENV` or native API | `codex_subagent(task: "", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie agent spawn --role docs` | - -Default to **Claude Code** when detection is ambiguous. +```bash +# Spawn a docs subagent +genie spawn docs +``` ## Rules - Validate every claim against actual code — no fiction. diff --git a/skills/dream/SKILL.md b/skills/dream/SKILL.md index 083f94bec..1a9ece0d3 100644 --- a/skills/dream/SKILL.md +++ b/skills/dream/SKILL.md @@ -12,7 +12,7 @@ Pick SHIP-ready wishes, build a dependency-ordered execution plan, spawn paralle - Multiple SHIP-ready wishes exist in `.genie/brainstorm.md` under `Poured` ## Flow -1. **Pick wishes** from `.genie/brainstorm.md`. +1. **Pick wishes** from `.genie/brainstorm.md` in the shared worktree. 2. **Generate DREAM.md** with dependency-ordered execution plan. 3. **Human confirms** DREAM.md (may edit before run). 4. **Phase 1 — Execute:** spawn workers per wish, collect outcomes. @@ -47,75 +47,35 @@ Pick SHIP-ready wishes, build a dependency-ordered execution plan, spawn paralle |-------|-------| | `slug` | wish identifier | | `branch` | `feat/` | -| `worktree_path` | `/tmp/dream-worktrees/` | | `wish_path` | `.genie/wishes//WISH.md` | -| `worker_prompt` | self-contained instructions (wish_path, branch, worktree_path, CI command, reporting format) | +| `worker_prompt` | self-contained instructions (wish_path, branch, CI command, reporting format) | | `depends_on` | upstream slugs from WISH.md | | `merge_order` | integer from topological layering | -4. Write to `.genie/DREAM.md` in the target repository (not the skills repo). +4. Write to `.genie/DREAM.md` in the shared worktree. 5. Present for human confirmation before execution. ## Dispatch -Runtime-specific dispatch. Detect runtime, then use the matching pattern. Default: Claude Code. - -| Signal | Runtime | Dispatch path | -|--------|---------|---------------| -| `Task` tool available with `isolation: "worktree"` | Claude Code | Teams + worktrees | -| Codex environment (`CODEX_ENV` or native subagent API) | Codex | Native subagents | -| Neither above, `term` CLI available | OpenClaw | term -> Claude Code -> Teams | - -### Claude Code (primary) - -``` -TeamCreate("dream-") - -Task( - model: "sonnet", - isolation: "worktree", - prompt: "", - run_in_background: true -) -``` - -- Parallel tasks: multiple `Task` calls with `run_in_background: true` in one message. -- Review tasks: separate `Task` (different subagent from implementor). -- Coordination: `SendMessage` for inter-agent communication. -- Cleanup: `TeamDelete` after all workers report. - -### Codex - -``` -codex_subagent( - task: "", - sandbox: true -) -``` - -Isolation is Codex-managed. Documented for future validation. - -### OpenClaw +All dispatch uses `genie spawn`. Create a team per dream session for isolation. ```bash -genie agent spawn --role implementor -# Or for bead-tracked work: -bd create "" --type task -genie work -``` - -Three-layer chain: OpenClaw -> genie -> Claude Code -> Teams. Use timeouts; fall back to sequential on failure. +# Create a team for this dream session +genie team create dream- -Full reference: `plugins/genie/references/dispatch-contract.md`. +# Spawn workers +genie spawn implementor # one per wish +genie spawn reviewer # one per PR (separate from implementor) +genie spawn fixer # for FIX-FIRST gaps (separate from both) +``` ## Phase 1: Execute -1. Detect runtime (see Dispatch above). -2. Create team context (Claude Code: `TeamCreate("dream-")`). -3. For each wish in DREAM.md, ordered by `merge_order` layer: +1. Create team context: `genie team create dream-`. +2. For each wish in DREAM.md, ordered by `merge_order` layer: - Same-layer wishes dispatch in parallel. - - Dispatch one worker per wish using the runtime's dispatch pattern, passing `worker_prompt` from DREAM.md. -4. Collect outcomes via `SendMessage` (Claude Code) or runtime-equivalent. + - Dispatch one worker per wish via `genie spawn implementor`, passing `worker_prompt` from DREAM.md. +3. Collect outcomes via `genie send`/`genie broadcast`. ### Worker Contract @@ -123,13 +83,13 @@ Each worker executes independently: 1. Read WISH.md from `wish_path`. 2. Self-refine task prompt via `/refine` (see Worker Self-Refinement below). -3. Checkout branch in worktree: `cd && git checkout -b `. +3. Checkout branch: `git checkout -b `. 4. Implement all execution groups from WISH.md. 5. CI fix loop (max 3 retries): - Run CI. If fail: fix, `sleep 5`, retry. - After 3 failures: mark BLOCKED. -6. Only after CI green: `gh pr create --base `. -7. Report to lead: +6. Only after CI green: `gh pr create --base dev`. +7. Report to lead via `genie send`: - Success: `DONE: PR at . CI: green. Groups: N/N.` - Failure: `BLOCKED: . Groups: N/N.` @@ -148,22 +108,19 @@ Workers NEVER overwrite WISH.md -- the refined prompt is runtime context only. **Trigger:** all execute workers have reported `DONE` or `BLOCKED`. -1. Dispatch one reviewer per open PR from Phase 1 using the runtime's dispatch pattern (see Dispatch above). Skip `BLOCKED` wishes. - - Reviewer must be a **separate subagent** from the implementor (enforced by dispatch contract). +1. Dispatch one reviewer per open PR from Phase 1 via `genie spawn reviewer`. Skip `BLOCKED` wishes. + - Reviewer must be a **separate subagent** from the implementor. 2. Reviewer loop (max 2 loops per PR): - Run `/review` against wish acceptance criteria. - - `FIX-FIRST`: dispatch fix subagent (separate from both implementor and reviewer), re-run `/review`. + - `FIX-FIRST`: dispatch `genie spawn fixer` (separate from both implementor and reviewer), re-run `/review`. - Architectural issue: escalate immediately (no fix attempt), record in report. - `SHIP`: mark review-complete. - - Update wish: set `**Status:** SHIPPED` in `.genie/wishes//WISH.md`. - - Close bead: `bd close ` (if unavailable, log warning -- non-blocking). 3. Cleanup: - - `git worktree remove ` for each wish. - - `TeamDelete` (Claude Code) or runtime-equivalent to tear down team context. + - `genie team disband dream-` to tear down team context. ## DREAM-REPORT.md -Write to `.genie/DREAM-REPORT.md` with this structure: +Write to `.genie/DREAM-REPORT.md` in the shared worktree with this structure: ### Reviewed PRs @@ -179,6 +136,7 @@ Write to `.genie/DREAM-REPORT.md` with this structure: ## Rules - Never early-stop: if a wish returns BLOCKED, record reason and continue with remaining wishes. - Never skip Phase 2 -- every DONE PR must be reviewed. -- Orchestrator never executes wish work directly -- always dispatch workers. +- Orchestrator never executes wish work directly -- always dispatch workers via `genie spawn`. - Do not expand scope beyond what WISH.md defines. - Always write DREAM-REPORT.md, even if all wishes BLOCKED. +- **No state management** — this skill does NOT write `Status: SHIPPED` or close tracking artifacts. State transitions are handled by the orchestration layer. diff --git a/skills/fix/SKILL.md b/skills/fix/SKILL.md index 7b7cf1a2e..8e1ceb1da 100644 --- a/skills/fix/SKILL.md +++ b/skills/fix/SKILL.md @@ -14,7 +14,7 @@ Resolve FIX-FIRST gaps from `/review`. Dispatch a fix subagent, re-review, repea ## Flow 1. **Parse gaps:** extract gap list from FIX-FIRST verdict — severity, files, failing checks. 2. **Dispatch fixer:** send gaps + original wish criteria to fix subagent. -3. **Re-review:** dispatch `/review` to validate the fix against the same pipeline. +3. **Re-review:** dispatch review subagent to validate the fix against the same pipeline. 4. **Evaluate verdict:** | Verdict | Condition | Action | @@ -39,13 +39,13 @@ Remaining gaps: Fix and re-review must be **separate dispatches** — never combine them in one subagent. -| Runtime | Detection | Fix dispatch | Re-review dispatch | -|---------|-----------|-------------|-------------------| -| Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | -| Codex | `CODEX_ENV` or native API | `codex_subagent(task: "", sandbox: true)` | `codex_subagent(task: "", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie agent spawn --role fixer` | `genie agent spawn --role reviewer` | +```bash +# Spawn a fixer subagent +genie spawn fixer -Default to **Claude Code** when detection is ambiguous. +# Spawn a reviewer subagent (separate from fixer) +genie spawn reviewer +``` ## Rules - Never fix and review in the same session — always separate subagents. diff --git a/skills/onboarding/SKILL.md b/skills/onboarding/SKILL.md index aae1b744c..37d890db9 100644 --- a/skills/onboarding/SKILL.md +++ b/skills/onboarding/SKILL.md @@ -229,11 +229,11 @@ Write a personalized AGENTS.md using this template, substituting gathered values - Branch workflow: dev (working) → main (production) ## Agent Commands (genie CLI) -- Spawn agent: `genie agent spawn --role ` -- List agents: `genie agent list` +- Spawn agent: `genie spawn ` +- List agents: `genie ls` - Send message: `genie send "" --to ` -- Kill agent: `genie agent kill ` -- Manage teams: `genie team ensure ` +- Kill agent: `genie kill ` +- Manage teams: `genie team create ` ## Session Protocol 1. Read this file at session start @@ -251,7 +251,7 @@ Adapt the template to the user's role: **4b. Configure Default Team** (if integrations selected) ```bash -genie team ensure default +genie team create default ``` **4c. Inject Hooks (CRITICAL)** @@ -433,19 +433,19 @@ End with a brief, friendly message welcoming them and suggesting their first act ## CLI Command Reference -**IMPORTANT:** The genie CLI uses `genie agent`, NOT `genie worker`. The worker→agent rename is complete. Always use: +**IMPORTANT:** The genie CLI uses `genie spawn` for agent creation and `genie agent` for management. The worker→agent rename is complete. Always use: | Action | Correct Command | WRONG (deprecated) | |--------|----------------|---------------------| -| Spawn agent | `genie agent spawn --role ` | ~~genie worker spawn~~ | -| List agents | `genie agent list` | ~~genie worker list~~ | -| Kill agent | `genie agent kill ` | ~~genie worker kill~~ | +| Spawn agent | `genie spawn ` | ~~genie worker spawn~~ | +| List agents | `genie ls` | ~~genie worker list~~ | +| Kill agent | `genie kill ` | ~~genie worker kill~~ | | Agent history | `genie agent history ` | ~~genie worker history~~ | | Send message | `genie send "" --to ` | ~~genie msg send~~ | -| Manage teams | `genie team ensure ` | (same) | +| Manage teams | `genie team create ` | (same) | | Install hooks | `genie hook install` | (none) | -If the generated AGENTS.md or any documentation references `genie worker`, replace with `genie agent`. +If the generated AGENTS.md or any documentation references `genie worker`, replace with the correct `genie spawn`/`genie agent` commands. ## tmux Best Practices @@ -524,7 +524,7 @@ set -g status-interval 5 # Refresh every 5s ### TEAM_LEAD_PROMPT.md Outdated References -`TEAM_LEAD_PROMPT.md` has been updated to use `genie agent spawn/list/kill/etc.` — consistent with the onboarding skill and all other documentation. +`TEAM_LEAD_PROMPT.md` has been updated to use `genie spawn/agent list/agent kill/etc.` — consistent with the onboarding skill and all other documentation. ## Error Handling @@ -551,6 +551,6 @@ set -g status-interval 5 # Refresh every 5s - Keep the tone warm but efficient — friendly without being verbose. - If the user seems experienced, adapt — offer to fast-track with sensible defaults. - The entire onboarding should complete in under 2 minutes of user time. -- Always use `genie agent` commands, NEVER `genie worker` (deprecated). +- Always use `genie spawn` for spawning and `genie agent` for management, NEVER `genie worker` (deprecated). - Hook injection is mandatory — never defer to first agent spawn. - Validate workspace structure before user interaction — fix silently, report in summary. diff --git a/skills/report/SKILL.md b/skills/report/SKILL.md index ad25d4789..062c0e096 100644 --- a/skills/report/SKILL.md +++ b/skills/report/SKILL.md @@ -181,13 +181,12 @@ The report must always be produced. The only question is how rich the evidence i Report orchestrates multiple tools but must **never modify source code** — investigation only. -| Runtime | Detection | Trace dispatch | Browser dispatch | -|---------|-----------|---------------|-----------------| -| Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | Direct `agent-browser` commands | -| Codex | `CODEX_ENV` or native API | `codex_subagent(task: "", sandbox: true)` | Direct `agent-browser` commands | -| OpenClaw | `genie` CLI available | `genie agent spawn --role tracer` | Direct `agent-browser` commands | +```bash +# Spawn a tracer subagent for investigation +genie spawn tracer +``` -Default to **Claude Code** when detection is ambiguous. +Browser dispatch uses direct `agent-browser` commands alongside the trace subagent. ## Rules - Always run `/trace` first — it is the backbone of every report. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index 5b940a770..0a54ea710 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -7,6 +7,15 @@ description: "Validate plans, execution, or PRs against wish criteria — return Validate any artifact against its wish criteria. Dispatch as a subagent — never review your own work inline. +## Context Injection + +This skill receives its scope from the dispatch layer: +- **Target** — what is being reviewed (wish draft, completed work, or PR diff) +- **Wish path** — `.genie/wishes//WISH.md` in the shared worktree +- **Injected criteria** — acceptance criteria extracted from the wish + +If context is injected, use it directly. Do not re-parse for information already provided. + ## When to Use - Before `/work` — validate a wish plan is ready for execution - After `/work` — verify implementation meets acceptance criteria @@ -21,6 +30,13 @@ Validate any artifact against its wish criteria. Dispatch as a subagent — neve 6. **Return verdict** — one of SHIP, FIX-FIRST, or BLOCKED (see Verdicts). 7. **Write next steps** — exact fixes, files, and commands for each gap. +## Council Participation + +When a council team is active, the review can incorporate council perspectives: +- Post review findings to team chat for council input via `genie chat post --team ` +- Council members may surface risks or blind spots missed by the standard checklist +- Council input is advisory — the verdict is still determined by the checklist + ## Pipelines ### Plan Review (before `/work`) @@ -69,13 +85,10 @@ Validate any artifact against its wish criteria. Dispatch as a subagent — neve **The reviewer must not be the implementor.** Always dispatch review as a separate subagent. -| Runtime | Detection | Pattern | -|---------|-----------|---------| -| Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | -| Codex | `CODEX_ENV` or native API | `codex_subagent(task: "", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie agent spawn --role reviewer` | - -Default to **Claude Code** when detection is ambiguous. +```bash +# Spawn a reviewer subagent +genie spawn reviewer +``` ## Rules - Never mark PASS without evidence — verify, don't assume. diff --git a/skills/trace/SKILL.md b/skills/trace/SKILL.md index 33eef5518..d379c7e7d 100644 --- a/skills/trace/SKILL.md +++ b/skills/trace/SKILL.md @@ -35,13 +35,10 @@ Confidence: Trace must run in **isolation** — the subagent must not modify any source files. -| Runtime | Detection | Trace dispatch | -|---------|-----------|---------------| -| Claude Code | `Task` tool available | `Task(model: "sonnet", isolation: "worktree", prompt: "")` | -| Codex | `CODEX_ENV` or native API | `codex_subagent(task: "", sandbox: true)` | -| OpenClaw | `genie` CLI available | `genie agent spawn --role tracer` | - -Default to **Claude Code** when detection is ambiguous. +```bash +# Spawn a tracer subagent (read-only investigation) +genie spawn tracer +``` ## Rules - Never fix during trace — investigation only, always separate from correction. diff --git a/skills/wish/SKILL.md b/skills/wish/SKILL.md index 3abf99378..303d7ae53 100644 --- a/skills/wish/SKILL.md +++ b/skills/wish/SKILL.md @@ -12,6 +12,13 @@ Convert a validated idea into an executable wish document at `.genie/wishes//WISH.md`. The orchestrator never executes directly — always dispatch via subagent. +## Context Injection + +This skill receives its execution context from the dispatch layer: +- **Wish path** — `.genie/wishes//WISH.md` in the shared worktree +- **Group context** — which execution group(s) to work on +- **Injected section** — the specific group definition extracted from the wish + +If context is injected, use it directly. Do not re-parse the wish for information already provided. + ## Flow -1. **Load wish + status:** read `.genie/wishes//WISH.md`, confirm scope and current progress. -2. **Pick next task:** select next unblocked pending execution group. +1. **Load wish:** read `.genie/wishes//WISH.md` from the shared worktree, confirm scope. +2. **Pick next task:** select next unblocked pending execution group (or use injected group context). 3. **Self-refine:** dispatch `/refine` on the task prompt (text mode) with WISH.md as context anchor. Read output from `/tmp/prompts/.md`. Fallback: proceed with original prompt if refiner fails (non-blocking). 4. **Dispatch worker:** send the task to a fresh subagent session (see Dispatch). -5. **Spec review:** dispatch `/review` subagent to check acceptance criteria. On FIX-FIRST, dispatch `/fix` (max 2 loops). -6. **Quality review:** dispatch `/review` subagent for quality pass (security, maintainability, perf). On FIX-FIRST, dispatch `/fix` (max 1 loop). +5. **Spec review:** dispatch review subagent to check acceptance criteria. On FIX-FIRST, dispatch fix subagent (max 2 loops). +6. **Quality review:** dispatch review subagent for quality pass (security, maintainability, perf). On FIX-FIRST, dispatch fix subagent (max 1 loop). 7. **Validate:** run the group validation command, record evidence. -8. **Mark complete:** update task state and wish checkboxes. +8. **Signal completion:** notify the leader via `genie send 'Group N complete' --to `. 9. **Repeat** steps 2-8 until all groups done. 10. **Handoff:** `All work tasks complete. Run /review.` -11. **Close:** set `**Status:** SHIPPED` in wish file (replace existing Status line). Call `bd close ` (log warning and continue if unavailable). ## When to Use - An approved wish exists and is ready for execution @@ -27,59 +35,27 @@ Orchestrate execution of an approved wish from `.genie/wishes//WISH.md`. T ## Dispatch -Detect runtime: `Task` tool with `isolation: "worktree"` → Claude Code. `CODEX_ENV` set → Codex. Otherwise → OpenClaw. Default to Claude Code if ambiguous. +All dispatch uses the `genie spawn` command. The orchestrator spawns subagents for each role — never executes work directly. -### Claude Code (primary) +```bash +# Spawn an implementor for the task +genie spawn implementor -``` -TeamCreate("work-") - -Task( - model: "sonnet", - isolation: "worktree", - prompt: "", - run_in_background: true -) +# Spawn a reviewer (always separate from implementor) +genie spawn reviewer + +# Spawn a fixer for FIX-FIRST gaps +genie spawn fixer ``` | Need | Method | |------|--------| -| Implementation task | `Task` with `isolation: "worktree"`, `model: "sonnet"` | -| Review task | Separate `Task` subagent (never same agent as implementor) | -| Parallel tasks | Multiple `Task` calls with `run_in_background: true` | +| Implementation task | `genie spawn implementor` | +| Review task | `genie spawn reviewer` (never same agent as implementor) | +| Fix task | `genie spawn fixer` (separate from reviewer) | | Quick validation | `Bash` tool directly — no subagent needed | -Coordinate via `SendMessage`. Clean up via `TeamDelete` after all workers report. - -### Codex - -``` -codex_subagent( - task: "", - sandbox: true -) -``` - -Isolation and model managed by Codex runtime. Collect responses via native API. - -### OpenClaw (via `genie`) - -Three-layer chain: OpenClaw → `genie` → Claude Code → Teams. - -```bash -# Heavy multi-file work with bead tracking -bd create "" --type task -genie work - -# Or spawn directly -genie agent spawn --role implementor - -# Monitor -genie agent list -genie agent read -``` - -Fallback when beads unavailable: use timeouts — 3 layers of indirection can stall. +Coordinate via `genie send '' --to `. Use `genie broadcast ''` for team-wide updates. ## Escalation @@ -95,3 +71,4 @@ When a subagent fails or fix loop limit (2) is exceeded: - Never skip validation commands. - Never overwrite WISH.md from workers — refined prompts are runtime context only. - Keep work auditable: capture commands + outcomes. +- **No state management** — this skill does NOT update task progress markers, write status lines, or close tracking artifacts. State transitions are handled by the orchestration layer. diff --git a/src/__tests__/beads-issues-jsonl.test.ts b/src/__tests__/beads-issues-jsonl.test.ts deleted file mode 100644 index 9037a86ea..000000000 --- a/src/__tests__/beads-issues-jsonl.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { ensureBeadsIssuesPath, upsertBeadsIssueJsonl } from '../lib/beads-issues-jsonl.js'; - -describe('beads-issues-jsonl', () => { - test('upsert is idempotent and preserves created_at', async () => { - const dir = await mkdtemp(join(tmpdir(), 'genie-cli-beads-')); - const issuesPath = await ensureBeadsIssuesPath(dir); - - const r1 = await upsertBeadsIssueJsonl(issuesPath, { - id: 'my-wish', - title: 'My Wish', - status: 'open', - source_repo: 'git:file:/tmp/repo', - wish_slug: 'my-wish', - depends_on: ['hq-roadmap'], - }); - - // update title, ensure created_at preserved and only one line exists - const r2 = await upsertBeadsIssueJsonl(issuesPath, { - id: 'my-wish', - title: 'My Wish (updated)', - status: 'open', - source_repo: 'git:file:/tmp/repo', - wish_slug: 'my-wish', - depends_on: ['hq-roadmap'], - }); - - expect(r2.created_at).toBe(r1.created_at); - expect(r2.updated_at).not.toBe(r1.updated_at); - - const content = await readFile(issuesPath, 'utf-8'); - const lines = content.split('\n').filter((l) => l.trim()); - expect(lines.length).toBe(1); - - const parsed = JSON.parse(lines[0]); - expect(parsed.id).toBe('my-wish'); - expect(parsed.title).toBe('My Wish (updated)'); - }); - - test('preserves malformed lines', async () => { - const dir = await mkdtemp(join(tmpdir(), 'genie-cli-beads-')); - const issuesPath = await ensureBeadsIssuesPath(dir); - - await writeFile(issuesPath, '{not json}\n', 'utf-8'); - - await upsertBeadsIssueJsonl(issuesPath, { - id: 'a', - title: 'A', - status: 'open', - source_repo: 'git:file:/tmp/repo', - wish_slug: 'a', - depends_on: ['hq-roadmap'], - }); - - const content = await readFile(issuesPath, 'utf-8'); - expect(content).toContain('{not json}'); - expect(content).toContain('"id":"a"'); - }); -}); diff --git a/src/__tests__/brainstorm-crystallize.test.ts b/src/__tests__/brainstorm-crystallize.test.ts deleted file mode 100644 index 8d73b4167..000000000 --- a/src/__tests__/brainstorm-crystallize.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { brainstormCrystallizeCommand } from '../genie-commands/brainstorm/crystallize.js'; - -async function mkRepo(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'genie-cli-crystallize-')); - // minimal git config not required; getSourceRepo may read package.json or git. - // Provide package.json to satisfy source_repo discovery in most cases. - await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'tmp-repo' }, null, 2)); - return dir; -} - -describe('genie brainstorm crystallize', () => { - test('writes design.md and upserts beads issue with default depends_on', async () => { - const repo = await mkRepo(); - const slug = 'my-idea'; - const draftPath = join(repo, '.genie', 'brainstorms', slug, 'draft.md'); - await mkdir(join(repo, '.genie', 'brainstorms', slug), { recursive: true }); - await writeFile(draftPath, '# Draft\n\nHello'); - - // should not throw - await brainstormCrystallizeCommand({ slug, repo }); - - const designPath = join(repo, '.genie', 'brainstorms', slug, 'design.md'); - const design = await readFile(designPath, 'utf-8'); - expect(design).toContain('Hello'); - - const issuesPath = join(repo, '.beads', 'issues.jsonl'); - const jsonl = await readFile(issuesPath, 'utf-8'); - const lines = jsonl.split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - - const rec = JSON.parse(lines[lines.length - 1]); - expect(rec.id).toBe(slug); - expect(rec.depends_on).toEqual(['hq-roadmap']); - }); -}); diff --git a/src/genie-commands/__tests__/session.test.ts b/src/genie-commands/__tests__/session.test.ts index 705fd4687..d4932ec53 100644 --- a/src/genie-commands/__tests__/session.test.ts +++ b/src/genie-commands/__tests__/session.test.ts @@ -53,10 +53,9 @@ describe('buildClaudeCommand', () => { expect(cmd).toContain("--agent-name 'team-lead'"); }); - test('with system prompt references file via $(cat)', () => { + test('with system prompt references file via --append-system-prompt-file', () => { const cmd = buildClaudeCommand('genie', 'test prompt'); - expect(cmd).toContain('--append-system-prompt'); - expect(cmd).toContain('$(cat'); + expect(cmd).toContain('--append-system-prompt-file'); expect(cmd).toContain('.genie/prompts/genie.md'); }); @@ -75,10 +74,10 @@ describe('buildClaudeCommand', () => { test('system prompt is persisted to file, not inlined', () => { const cmd = buildClaudeCommand('genie', "it's a test with a very long prompt"); - expect(cmd).toContain('--append-system-prompt'); - // Prompt content NOT in the command — only the $(cat) reference + expect(cmd).toContain('--append-system-prompt-file'); + // Prompt content NOT in the command — only the file path reference expect(cmd).not.toContain('very long prompt'); - expect(cmd).toContain('$(cat'); + expect(cmd).toContain('.genie/prompts/genie.md'); }); }); diff --git a/src/genie-commands/brainstorm/crystallize.ts b/src/genie-commands/brainstorm/crystallize.ts deleted file mode 100644 index 4feb29f43..000000000 --- a/src/genie-commands/brainstorm/crystallize.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { basename, join, resolve } from 'node:path'; -import { crystallizeBrainstormAndUpsertBeads } from '../../term-commands/brainstorm-beads.js'; - -interface BrainstormCrystallizeOptions { - slug: string; - file?: string; - repo?: string; - title?: string; - dependsOn?: string; - status?: 'open' | 'closed'; -} - -function slugFromPath(p: string): string | null { - // .genie/brainstorms//draft.md - const m = p.replace(/\\/g, '/').match(/\.genie\/brainstorms\/([^/]+)\//); - return m?.[1] ?? null; -} - -export async function brainstormCrystallizeCommand(options: BrainstormCrystallizeOptions): Promise { - const repoPath = resolve(options.repo || process.cwd()); - - const slug = options.slug; - if (!slug) { - console.error('❌ Missing required: --slug '); - process.exit(1); - } - - const defaultDraft = join(repoPath, '.genie', 'brainstorms', slug, 'draft.md'); - const draftPath = resolve(repoPath, options.file ?? defaultDraft); - - let draft: string; - try { - draft = await readFile(draftPath, 'utf-8'); - } catch { - console.error(`❌ Could not read draft file: ${draftPath}`); - process.exit(1); - } - - const title = options.title || slug; - const dependsOn = options.dependsOn - ? options.dependsOn - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : undefined; - - const { designPath, beadsPath } = await crystallizeBrainstormAndUpsertBeads({ - repoPath, - slug, - title, - designMarkdown: draft, - status: options.status, - dependsOn, - }); - - // Basic guardrails / helpful output - if (basename(draftPath) !== 'draft.md' && !options.title) { - const inferred = slugFromPath(draftPath); - if (inferred && inferred !== slug) { - console.error(`ℹ️ Note: slug inferred from file path would be "${inferred}" (you passed --slug ${slug})`); - } - } - - console.log(designPath); - console.log(beadsPath); -} diff --git a/src/genie-commands/ledger/validate.ts b/src/genie-commands/ledger/validate.ts deleted file mode 100644 index 38de401a6..000000000 --- a/src/genie-commands/ledger/validate.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type BeadsValidateOptions, beadsValidateCommand } from '../../term-commands/beads-validate.js'; - -export async function ledgerValidateCommand(options: BeadsValidateOptions): Promise { - await beadsValidateCommand(options); -} diff --git a/src/genie-commands/profiles.ts b/src/genie-commands/profiles.ts deleted file mode 100644 index 657cd7ad9..000000000 --- a/src/genie-commands/profiles.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * genie profiles - Worker profile management commands - * - * Manages worker profiles in ~/.genie/config.json for configuring - * how Claude Code workers are spawned (launcher, args, etc.) - */ - -import { confirm, input } from '@inquirer/prompts'; -import { loadGenieConfig, saveGenieConfig } from '../lib/genie-config.js'; -import type { WorkerProfile } from '../types/genie-config.js'; - -/** - * genie profiles list - Show all configured worker profiles - */ -export async function profilesListCommand(): Promise { - const config = await loadGenieConfig(); - const profiles = config.workerProfiles || {}; - const profileNames = Object.keys(profiles); - - if (profileNames.length === 0) { - console.log('\nNo worker profiles configured.\n'); - console.log('Run `genie profiles add ` to create one.'); - console.log('Or copy the template: cp templates/genie-config.template.json ~/.genie/config.json\n'); - return; - } - - console.log('\n\x1b[1mWorker Profiles\x1b[0m\n'); - - // Header - const nameWidth = 20; - const launcherWidth = 12; - console.log(` ${'Name'.padEnd(nameWidth)}${'Launcher'.padEnd(launcherWidth)}Claude Args`); - console.log(` ${'-'.repeat(nameWidth - 2)} ${'-'.repeat(launcherWidth - 2)} ${'-'.repeat(30)}`); - - // Rows - for (const name of profileNames) { - const profile = profiles[name]; - const isDefault = config.defaultWorkerProfile === name; - const marker = isDefault ? '*' : ' '; - const args = profile.claudeArgs.join(' ') || '-'; - - console.log(` ${marker}${name.padEnd(nameWidth - 1)}${profile.launcher.padEnd(launcherWidth)}${args}`); - } - - console.log(''); - if (config.defaultWorkerProfile) { - console.log(` * = default profile (${config.defaultWorkerProfile})\n`); - } -} - -/** - * genie profiles add - Create a new worker profile interactively - */ -export async function profilesAddCommand(name: string): Promise { - const config = await loadGenieConfig(); - - // Check if profile already exists - if (config.workerProfiles?.[name]) { - console.error(`\x1b[31m✗ Profile '${name}' already exists.\x1b[0m`); - console.log('Use `genie profiles show ${name}` to view it.'); - console.log('Use `genie profiles rm ${name}` to delete it first.\n'); - process.exit(1); - } - - console.log(`\nCreating worker profile: ${name}\n`); - - const launcher = 'claude' as const; - - // Prompt for claude args - const claudeArgsStr = await input({ - message: 'Claude args (space-separated, e.g., --dangerously-skip-permissions --model opus):', - default: '--dangerously-skip-permissions', - }); - - const claudeArgs = claudeArgsStr.trim() ? claudeArgsStr.trim().split(/\s+/) : []; - - // Build profile - const profile: WorkerProfile = { - launcher, - claudeArgs, - }; - // Save - if (!config.workerProfiles) { - config.workerProfiles = {}; - } - config.workerProfiles[name] = profile; - await saveGenieConfig(config); - - console.log(`\n\x1b[32m✓ Profile '${name}' created.\x1b[0m\n`); - - // Offer to set as default if no default exists - if (!config.defaultWorkerProfile) { - const setDefault = await confirm({ - message: 'Set as default profile?', - default: true, - }); - if (setDefault) { - config.defaultWorkerProfile = name; - await saveGenieConfig(config); - console.log(`\x1b[32m✓ Default profile set to '${name}'.\x1b[0m\n`); - } - } -} - -/** - * genie profiles rm - Delete a worker profile - */ -export async function profilesRmCommand(name: string): Promise { - const config = await loadGenieConfig(); - - // Check if profile exists - if (!config.workerProfiles?.[name]) { - console.error(`\x1b[31m✗ Profile '${name}' not found.\x1b[0m`); - const available = Object.keys(config.workerProfiles || {}); - if (available.length > 0) { - console.log(`Available profiles: ${available.join(', ')}\n`); - } - process.exit(1); - } - - // Confirm deletion - const confirmed = await confirm({ - message: `Delete profile '${name}'?`, - default: false, - }); - - if (!confirmed) { - console.log('Cancelled.\n'); - return; - } - - // Delete - delete config.workerProfiles[name]; - - // Clear default if it was this profile - if (config.defaultWorkerProfile === name) { - config.defaultWorkerProfile = undefined; - console.log(`\x1b[33m⚠ Cleared default profile (was '${name}')\x1b[0m`); - } - - await saveGenieConfig(config); - console.log(`\x1b[32m✓ Profile '${name}' deleted.\x1b[0m\n`); -} - -/** - * genie profiles show - Display profile details - */ -export async function profilesShowCommand(name: string): Promise { - const config = await loadGenieConfig(); - - const profile = config.workerProfiles?.[name]; - if (!profile) { - console.error(`\x1b[31m✗ Profile '${name}' not found.\x1b[0m`); - const available = Object.keys(config.workerProfiles || {}); - if (available.length > 0) { - console.log(`Available profiles: ${available.join(', ')}\n`); - } - process.exit(1); - } - - const isDefault = config.defaultWorkerProfile === name; - - console.log(`\n\x1b[1mProfile: ${name}\x1b[0m${isDefault ? ' (default)' : ''}\n`); - console.log(` Launcher: ${profile.launcher}`); - console.log(` Claude Args: ${profile.claudeArgs.join(' ') || '(none)'}`); - - // Show example spawn command - console.log('\n \x1b[2mSpawn command preview:\x1b[0m'); - console.log(` claude ${profile.claudeArgs.join(' ')}`); - console.log(''); -} - -/** - * genie profiles default [name] - Get or set default profile - */ -export async function profilesDefaultCommand(name?: string): Promise { - const config = await loadGenieConfig(); - - // If no name provided, show current default - if (!name) { - if (config.defaultWorkerProfile) { - console.log(`\nDefault profile: ${config.defaultWorkerProfile}\n`); - } else { - console.log('\nNo default profile set.'); - console.log('Run `genie profiles default ` to set one.\n'); - } - return; - } - - // Validate profile exists - if (!config.workerProfiles?.[name]) { - console.error(`\x1b[31m✗ Profile '${name}' not found.\x1b[0m`); - const available = Object.keys(config.workerProfiles || {}); - if (available.length > 0) { - console.log(`Available profiles: ${available.join(', ')}\n`); - } - process.exit(1); - } - - // Set default - config.defaultWorkerProfile = name; - await saveGenieConfig(config); - console.log(`\x1b[32m✓ Default profile set to '${name}'.\x1b[0m\n`); -} diff --git a/src/genie-commands/session.ts b/src/genie-commands/session.ts index 55c8b57d7..6c43fc040 100644 --- a/src/genie-commands/session.ts +++ b/src/genie-commands/session.ts @@ -1,38 +1,15 @@ /** - * Genie Session Command + * Genie Session Helpers * - * Session-per-folder: running `genie` from any folder creates/attaches - * a tmux window named after that folder inside a single "genie" session. - * - * Architecture: - * tmux session: "genie" <- single persistent session - * |-- Window 0: "myapp" <- genie run from ~/projects/myapp - * |-- Window 1: "api-server-c7b1" <- disambiguated (same basename, different path) - * +-- Window 2: "myapp2" <- genie run from ~/projects/myapp2 + * Exports used by other modules: + * - getAgentsSystemPrompt() — reads AGENTS.md from cwd + * - buildClaudeCommand() — builds claude CLI launch command + * - sanitizeWindowName() — sanitizes tmux window names */ -import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; -import { - deleteNativeTeam, - ensureNativeTeam, - registerNativeMember, - sanitizeTeamName, -} from '../lib/claude-native-teams.js'; -import { buildTeamLeadCommand, shellQuote } from '../lib/team-lead-command.js'; -import * as tmux from '../lib/tmux.js'; - -const DEFAULT_SESSION_NAME = 'genie'; - -/** - * Generate a short 4-char hash of a path for disambiguation. - */ -function shortPathHash(p: string): string { - return createHash('md5').update(p).digest('hex').slice(0, 4); -} +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildTeamLeadCommand } from '../lib/team-lead-command.js'; /** * Get the AGENTS.md system prompt if it exists in the current directory. @@ -46,78 +23,6 @@ export function getAgentsSystemPrompt(): string | null { return null; } -/** - * Convert a workspace directory path to a Claude project directory name. - * Claude encodes paths by replacing '/' with '-', so /home/genie/workspace -> -home-genie-workspace. - */ -function workspaceDirToProjectDir(workspaceDir: string): string { - return workspaceDir.replace(/\//g, '-'); -} - -/** - * Find the most recent session ID for a given teamName + agentName from Claude's JSONL logs. - * Returns null if no matching session is found. - */ -function findLastSessionId(teamName: string, agentName: string, workspaceDir: string): string | null { - const projectDirName = workspaceDirToProjectDir(workspaceDir); - const projectDir = join(homedir(), '.claude', 'projects', projectDirName); - - if (!existsSync(projectDir)) return null; - - let files: { path: string; mtime: number }[]; - try { - files = readdirSync(projectDir) - .filter((f) => f.endsWith('.jsonl')) - .map((f) => { - const p = join(projectDir, f); - return { path: p, mtime: statSync(p).mtimeMs }; - }) - .sort((a, b) => b.mtime - a.mtime); - } catch { - return null; - } - - for (const { path } of files) { - try { - const firstLine = readFileSync(path, 'utf-8').split('\n')[0]; - if (!firstLine) continue; - const data = JSON.parse(firstLine); - if (data.teamName === teamName && data.agentName === agentName && data.sessionId) { - return data.sessionId as string; - } - } catch { - // Skip malformed files - } - } - return null; -} - -export interface SessionOptions { - reset?: boolean; - name?: string; - dir?: string; - /** Team name -- when set, focus (or create) a dedicated window for this team. */ - team?: string; -} - -/** - * Pre-create the native team directory so CC starts as team-lead. - * - * Creates ~/.claude/teams// with config.json + inboxes/team-lead.json. - * The leadSessionId is a placeholder -- CC updates it internally once started. - * CC recognizes itself as leader because --team-name is passed without --agent-id. - */ -async function ensureNativeTeamForLeader(teamName: string, cwd: string): Promise { - await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending'); - - await registerNativeMember(teamName, { - agentName: 'team-lead', - agentType: 'general-purpose', - color: 'blue', - cwd, - }); -} - /** * Build the claude launch command with native team flags. * Delegates to the shared buildTeamLeadCommand (single source of truth). @@ -126,118 +31,6 @@ export function buildClaudeCommand(teamName: string, systemPrompt?: string, resu return buildTeamLeadCommand(teamName, { systemPrompt, resumeSessionId }); } -/** - * Resolve the window name for the current working directory. - * - * Logic: - * 1. Window name starts as basename(cwd) - * 2. Check if a window with that name exists in the session - * 3. If it exists, read GENIE_CWD env var for that window - * 4. If GENIE_CWD matches current cwd -> reuse that window (return the name) - * 5. If GENIE_CWD differs (collision) -> append 4-char hash to disambiguate - * 6. If no window exists -> use the base name - */ -async function resolveWindowName(sessionName: string, cwd: string): Promise { - // Sanitize before lookup so collision detection matches what tmux actually stores. - // Without this, dotted folders (e.g. "foo.bar") bypass disambiguation because - // findWindowByName looks for "foo.bar" while the existing window is "foo-bar". - const baseName = sanitizeWindowName(basename(cwd)); - const existing = await tmux.findWindowByName(sessionName, baseName); - - if (!existing) { - // No window with this name exists, use the base name - return baseName; - } - - // Window exists — check if it's for the same cwd - const storedCwd = await tmux.getWindowEnv(`${sessionName}:${baseName}`, 'GENIE_CWD'); - if (storedCwd === cwd) { - // Same folder, reuse the window - return baseName; - } - - // Different folder with same basename — disambiguate with hash - return `${baseName}-${shortPathHash(cwd)}`; -} - -/** - * Create the initial tmux session and its first window for the given folder. - */ -async function createSession( - sessionName: string, - windowName: string, - workspaceDir: string, - systemPrompt: string | null, -): Promise { - await ensureNativeTeamForLeader(windowName, workspaceDir); - console.log(`Native team "${windowName}" ready at ~/.claude/teams/${sanitizeTeamName(windowName)}/`); - - console.log(`Creating session "${sessionName}"...`); - const session = await tmux.createSession(sessionName); - if (!session) { - console.error(`Failed to create session "${sessionName}"`); - process.exit(1); - } - - // Get the initial window ID (respects user's base-index setting -- don't hardcode :0) - const windows = await tmux.listWindows(sessionName); - const firstWindow = windows[0]; - if (!firstWindow) { - console.error(`Failed to find initial window in session "${sessionName}"`); - process.exit(1); - } - - // Name the first window after the folder and lock the name - await tmux.executeTmux(`rename-window -t ${shellQuote(firstWindow.id)} ${shellQuote(windowName)}`); - await tmux.executeTmux(`set-window-option -t ${shellQuote(firstWindow.id)} automatic-rename off`); - - // Store cwd as env var on the window - await tmux.setWindowEnv(`${sessionName}:${windowName}`, 'GENIE_CWD', workspaceDir); - - const target = `${sessionName}:${windowName}`; - const cdCmd = `cd ${shellQuote(workspaceDir)}`; - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cdCmd)} Enter`); - - const resumeSessionId = findLastSessionId(sanitizeTeamName(windowName), 'team-lead', workspaceDir); - if (resumeSessionId) { - console.log(`Resuming previous session: ${resumeSessionId}`); - } - const cmd = buildClaudeCommand(windowName, systemPrompt || undefined, resumeSessionId || undefined); - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cmd)} Enter`); - console.log(`Started Claude Code as team-lead@${sanitizeTeamName(windowName)} in ${workspaceDir}`); -} - -/** Focus (or create) a team window within an existing session. */ -async function focusTeamWindow( - sessionName: string, - windowName: string, - workingDir: string, - systemPrompt: string | null, -): Promise { - const teamWindow = await tmux.ensureTeamWindow(sessionName, windowName, workingDir); - if (teamWindow.created) { - console.log(`Created team window "${windowName}"`); - - // Store cwd as env var on the window - await tmux.setWindowEnv(`${sessionName}:${windowName}`, 'GENIE_CWD', workingDir); - - // Bootstrap native team and launch Claude Code in the new window - await ensureNativeTeamForLeader(windowName, workingDir); - const target = `${sessionName}:${windowName}`; - const cdCmd = `cd ${shellQuote(workingDir)}`; - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cdCmd)} Enter`); - const resumeSessionId = findLastSessionId(sanitizeTeamName(windowName), 'team-lead', workingDir); - if (resumeSessionId) { - console.log(`Resuming previous session: ${resumeSessionId}`); - } - const cmd = buildClaudeCommand(windowName, systemPrompt || undefined, resumeSessionId || undefined); - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cmd)} Enter`); - console.log(`Started Claude Code as team-lead@${sanitizeTeamName(windowName)} in ${workingDir}`); - } - await tmux.executeTmux(`select-window -t ${shellQuote(`${sessionName}:${windowName}`)}`); - console.log(`Focused team window "${windowName}"`); -} - /** * Sanitize a window name for tmux targeting. * tmux uses '.' as a pane separator in targets (session:window.pane), @@ -246,56 +39,3 @@ async function focusTeamWindow( export function sanitizeWindowName(name: string): string { return name.replace(/\./g, '-'); } - -async function deriveWindowName(sessionName: string, workspaceDir: string, team?: string): Promise { - if (team) return sanitizeWindowName(team); - const existingSession = await tmux.findSessionByName(sessionName); - if (existingSession) return sanitizeWindowName(await resolveWindowName(sessionName, workspaceDir)); - return sanitizeWindowName(basename(workspaceDir)); -} - -async function handleReset(sessionName: string, windowName: string): Promise { - const existing = await tmux.findSessionByName(sessionName); - if (existing) { - console.log(`Resetting session "${sessionName}"...`); - await tmux.killSession(sessionName); - } - await deleteNativeTeam(windowName); -} - -function attachToWindow(sessionName: string, windowName: string): void { - console.log('Attaching...'); - const target = `${sessionName}:${windowName}`; - const cmd = process.env.TMUX ? 'switch-client' : 'attach'; - spawnSync('tmux', [cmd, '-t', target], { stdio: 'inherit' }); -} - -export async function sessionCommand(options: SessionOptions = {}): Promise { - const sessionName = options.name ?? DEFAULT_SESSION_NAME; - const workspaceDir = options.dir ?? process.cwd(); - - try { - const windowName = await deriveWindowName(sessionName, workspaceDir, options.team); - - if (options.reset) await handleReset(sessionName, windowName); - - const session = await tmux.findSessionByName(sessionName); - const systemPrompt = getAgentsSystemPrompt(); - if (!systemPrompt) { - console.warn('Info: No AGENTS.md found in current directory. Team-lead will use orchestration rules only.'); - } - - if (!session) { - await createSession(sessionName, windowName, workspaceDir, systemPrompt); - } else { - console.log(`Session "${sessionName}" already exists`); - await focusTeamWindow(sessionName, windowName, workspaceDir, systemPrompt); - } - - attachToWindow(sessionName, windowName); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } -} diff --git a/src/genie.ts b/src/genie.ts index 305106a32..9739ba59f 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -1,25 +1,23 @@ #!/usr/bin/env bun /** - * genie — Single entrypoint CLI with namespaces: - * team, task, agent + top-level: work, daemon, council, send, inbox + * genie — Single entrypoint CLI. + * + * Top-level commands: + * spawn, kill, stop, ls, history, read, answer, work + * + * Namespaces: + * team, dir, send/inbox, state, hook + * + * Utilities: + * setup, doctor, update, uninstall, shortcuts + * + * Session: + * genie --session — Start or resume a named leader session */ -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { Command } from 'commander'; -import { brainstormCrystallizeCommand } from './genie-commands/brainstorm/crystallize.js'; import { doctorCommand } from './genie-commands/doctor.js'; -import { ledgerValidateCommand } from './genie-commands/ledger/validate.js'; -import { - profilesAddCommand, - profilesDefaultCommand, - profilesListCommand, - profilesRmCommand, - profilesShowCommand, -} from './genie-commands/profiles.js'; -import { type SessionOptions, sessionCommand } from './genie-commands/session.js'; import { type SetupOptions, setupCommand } from './genie-commands/setup.js'; import { shortcutsInstallCommand, @@ -28,25 +26,57 @@ import { } from './genie-commands/shortcuts.js'; import { uninstallCommand } from './genie-commands/uninstall.js'; import { updateCommand } from './genie-commands/update.js'; -import { sanitizeTeamName } from './lib/claude-native-teams.js'; -import { resolveTeamShortcut } from './lib/team-shortcut.js'; import { VERSION } from './lib/version.js'; import { registerHookNamespace } from './hooks/dispatch-command.js'; -import { registerAgentNamespace } from './term-commands/agents.js'; -import * as councilCmd from './term-commands/council.js'; -import * as daemonCmd from './term-commands/daemon.js'; +import { + type SpawnOptions, + handleLsCommand, + handleWorkerKill, + handleWorkerSpawn, + handleWorkerStop, +} from './term-commands/agents.js'; +import { registerDirNamespace } from './term-commands/dir.js'; +import { registerDispatchCommands } from './term-commands/dispatch.js'; +import * as historyCmd from './term-commands/history.js'; import { registerSendInboxCommands } from './term-commands/msg.js'; -import { registerTaskNamespace } from './term-commands/task/commands.js'; -// Provider-selectable orchestration namespaces (genie-cli-teams) +import * as orchestrateCmd from './term-commands/orchestrate.js'; +import * as readCmd from './term-commands/read.js'; +import { registerStateCommands } from './term-commands/state.js'; import { registerTeamNamespace } from './term-commands/team.js'; -import * as workCmd from './term-commands/work.js'; const program = new Command(); -program.name('genie').description('Genie CLI - Setup and utilities for AI-assisted development').version(VERSION); +program.name('genie').description('Genie CLI - AI-assisted development').version(VERSION); + +// ============================================================================ +// Named session — genie --session +// ============================================================================ + +async function startNamedSession(name: string): Promise { + const { getOrCreateSession } = await import('./lib/session-store.js'); + const { buildTeamLeadCommand } = await import('./lib/team-lead-command.js'); + const { getAgentsSystemPrompt } = await import('./genie-commands/session.js'); + + const { uuid, isNew } = await getOrCreateSession(name); + const systemPrompt = getAgentsSystemPrompt(); + + const cmd = buildTeamLeadCommand(name, { + systemPrompt: systemPrompt ?? undefined, + ...(isNew ? { sessionId: uuid } : { resumeSessionId: uuid }), + }); + + console.log(isNew ? `Starting new session: ${name}` : `Resuming session: ${name}`); + + const { spawnSync } = await import('node:child_process'); + const result = spawnSync('sh', ['-c', cmd], { stdio: 'inherit' }); + process.exit(result.status ?? 0); +} + +// ============================================================================ +// Utility commands +// ============================================================================ -// Setup command - configure genie settings program .command('setup') .description('Configure genie settings') @@ -61,187 +91,159 @@ program await setupCommand(options); }); -// Doctor command - diagnostic checks program.command('doctor').description('Run diagnostic checks on genie installation').action(doctorCommand); - -// Update command - pull latest and rebuild program.command('update').description('Update Genie CLI to the latest version').action(updateCommand); - -// Uninstall command - remove genie CLI program.command('uninstall').description('Remove Genie CLI and clean up hooks').action(uninstallCommand); -// Internal handler for session opening (hidden -- user invokes via `genie` or `genie `) -program - .command('_open [team]', { hidden: true }) - .option('-r, --reset', 'Kill existing session and start fresh') - .option('-d, --dir ', 'Working directory (default: cwd)') - .action(async (team: string | undefined, options: SessionOptions) => { - if (team) options.team = team; - await sessionCommand(options); - }); - -// Shortcuts command group - manage tmux keyboard shortcuts const shortcuts = program.command('shortcuts').description('Manage tmux keyboard shortcuts'); - -// Make 'show' the default action for bare `genie shortcuts` shortcuts.action(shortcutsShowCommand); - shortcuts.command('show').description('Show available shortcuts and installation status').action(shortcutsShowCommand); - shortcuts .command('install') .description('Install shortcuts to config files (~/.tmux.conf, shell rc)') .action(shortcutsInstallCommand); - shortcuts.command('uninstall').description('Remove shortcuts from config files').action(shortcutsUninstallCommand); -// Profiles command group - manage worker profiles -const profiles = program.command('profiles').description('Manage worker profiles for Claude Code spawning'); - -// Make 'list' the default action for bare `genie profiles` -profiles.action(profilesListCommand); - -profiles.command('list').description('List all configured worker profiles').action(profilesListCommand); - -profiles.command('add ').description('Create a new worker profile interactively').action(profilesAddCommand); - -profiles.command('rm ').description('Delete a worker profile').action(profilesRmCommand); - -profiles.command('show ').description('Show details of a worker profile').action(profilesShowCommand); - -profiles.command('default [name]').description('Get or set the default worker profile').action(profilesDefaultCommand); - -// Brainstorm command group -const brainstorm = program.command('brainstorm').description('Brainstorm utilities (file-based helpers)'); - -brainstorm - .command('crystallize') - .description('Crystallize a brainstorm draft into design.md and upsert .beads/issues.jsonl') - .requiredOption('--slug ', 'Brainstorm slug (kebab-case)') - .option('--file ', 'Input draft markdown path (default: .genie/brainstorms//draft.md)') - .option('-r, --repo ', 'Repo path (default: cwd)') - .option('--title ', 'Issue title (default: slug)') - .option('--depends-on <ids>', 'Comma-separated dependency IDs (default: hq-roadmap)') - .option('--status <status>', 'Issue status (open|closed)', 'open') - .action(brainstormCrystallizeCommand); - -// Ledger command group -const ledger = program.command('ledger').description('Ledger utilities (beads JSONL validation)'); - -ledger - .command('validate') - .description('Validate local .beads/issues.jsonl JSONL structure (scriptable)') - .option('-r, --repo <path>', 'Repo path (default: cwd)') - .option('--json', 'Output JSON') - .action(ledgerValidateCommand); - // ============================================================================ -// Provider-selectable orchestration namespaces (genie-cli-teams) +// Orchestration namespaces // ============================================================================ registerTeamNamespace(program); -registerAgentNamespace(program); +registerDirNamespace(program); registerSendInboxCommands(program); -registerTaskNamespace(program); +registerStateCommands(program); +registerDispatchCommands(program); registerHookNamespace(program); // ============================================================================ -// Top-level commands (migrated from genie term) +// Top-level agent commands (promoted from genie agent namespace) // ============================================================================ -// genie work <target> — spawn worker bound to beads issue +// genie spawn <name> program - .command('work <target>') - .description('Spawn worker bound to beads issue (target: bd-id, "next", or "wish")') - .option('--no-worktree', 'Use shared repo instead of worktree') - .option('-s, --session <name>', 'Target tmux session') - .option('--focus', 'Focus the worker pane after spawning') - .option('-p, --prompt <message>', 'Custom initial prompt') - .option('--no-resume', 'Start fresh session even if previous exists') - .option('--skill <name>', 'Skill to invoke (auto-detects "forge" if wish.md exists)') - .option('--no-auto-approve', 'Disable auto-approve for this worker') - .option('--profile <name>', 'Worker profile to use') - .option('-n, --name <name>', 'Custom worker name (for N workers per task)') - .option('-r, --role <role>', 'Worker role (e.g., "main", "tests", "review")') - .option('--shared-worktree', 'Share worktree with existing worker on same task') - .action(async (target: string, options: workCmd.WorkOptions) => { - await workCmd.workCommand(target, options); + .command('spawn <name>') + .description('Spawn a new agent by name (resolves from directory or built-ins)') + .option('--provider <provider>', 'Provider: claude or codex', 'claude') + .option('--team <team>', 'Team name', process.env.GENIE_TEAM ?? 'genie') + .option('--model <model>', 'Model override (e.g., sonnet, opus)') + .option('--skill <skill>', 'Skill to load (optional)') + .option('--layout <layout>', 'Layout mode: mosaic (default) or vertical') + .option('--color <color>', 'Teammate pane border color') + .option('--plan-mode', 'Start teammate in plan mode') + .option('--permission-mode <mode>', 'Permission mode (e.g., acceptEdits)') + .option('--extra-args <args...>', 'Extra CLI args forwarded to provider') + .option('--cwd <path>', 'Working directory for the agent (overrides directory entry)') + .action(async (name: string, options: SpawnOptions) => { + try { + await handleWorkerSpawn(name, options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } }); -// genie daemon — beads sync daemon -const daemonProgram = program.command('daemon').description('Manage beads daemon for auto-sync'); +// genie kill <name> +program + .command('kill <name>') + .description('Force kill an agent by name') + .action(async (name: string) => { + try { + await handleWorkerKill(name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); -daemonProgram - .command('start') - .description('Start beads daemon (auto-commit, auto-sync)') - .option('--no-auto-commit', 'Disable auto-commit') - .option('--auto-push', 'Enable auto-push to remote') - .action(async (options: daemonCmd.DaemonStartOptions) => { - await daemonCmd.startCommand(options); +// genie stop <name> +program + .command('stop <name>') + .description('Stop an agent (preserves session for resume)') + .action(async (name: string) => { + try { + await handleWorkerStop(name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } }); -daemonProgram - .command('stop') - .description('Stop beads daemon') - .action(async () => { - await daemonCmd.stopCommand(); +// genie history <name> +program + .command('history <name>') + .description('Show compressed session history for an agent') + .option('--full', 'Show full conversation without compression') + .option('--since <n>', 'Show last N user/assistant exchanges', Number.parseInt) + .option('--json', 'Output as JSON') + .option('--raw', 'Output raw JSONL entries') + .option('--log-file <path>', 'Direct path to log file (for testing)') + .action(async (name: string, options: historyCmd.HistoryOptions) => { + await historyCmd.historyCommand(name, options); }); -daemonProgram - .command('status') - .description('Show daemon status') +// genie read <name> +program + .command('read <name>') + .description('Read terminal output from an agent pane') + .option('-n, --lines <number>', 'Number of lines to read') + .option('--from <line>', 'Start line') + .option('--to <line>', 'End line') + .option('--range <range>', 'Line range (e.g., "10-20")') + .option('--search <text>', 'Search for text') + .option('--grep <pattern>', 'Grep for pattern') + .option('-f, --follow', 'Follow mode (like tail -f)') + .option('--all', 'Show all output') + .option('-r, --reverse', 'Reverse order') .option('--json', 'Output as JSON') - .action(async (options: daemonCmd.DaemonStatusOptions) => { - await daemonCmd.statusCommand(options); + .action(async (name: string, options: readCmd.ReadOptions) => { + await readCmd.readSessionLogs(name, options); }); -daemonProgram - .command('restart') - .description('Restart beads daemon') - .option('--no-auto-commit', 'Disable auto-commit') - .option('--auto-push', 'Enable auto-push to remote') - .action(async (options: daemonCmd.DaemonStartOptions) => { - await daemonCmd.restartCommand(options); +// genie answer <name> <choice> +program + .command('answer <name> <choice>') + .description('Answer a question for an agent (use "text:..." for text input)') + .action(async (name: string, choice: string) => { + await orchestrateCmd.answerQuestion(name, choice); }); -// genie council — dual-model deliberation +// genie ls — smart agent view program - .command('council') - .description('Spawn dual Claude instances for multi-model deliberation') - .option('-s, --session <name>', 'Target tmux session') - .option('--preset <name>', 'Council preset to use') - .option('--skill <skill>', 'Skill to load on both instances') - .option('--no-focus', "Don't focus the new window") - .action(async (options: councilCmd.CouncilOptions) => { - await councilCmd.councilCommand(options); + .command('ls') + .description('List registered agents with runtime status') + .option('--json', 'Output as JSON') + .action(async (options: { json?: boolean }) => { + try { + await handleLsCommand(options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } }); // ============================================================================ -// Team shortcut routing: genie <team> -> genie _open <team> +// genie --session <name> — named leader session (pre-parse check) // ============================================================================ -// Collect all registered subcommand names (+ aliases) -const knownCommands = new Set<string>(); -for (const cmd of program.commands) { - knownCommands.add(cmd.name()); - for (const alias of cmd.aliases()) { - knownCommands.add(alias); +const args = process.argv.slice(2); +const sessionIdx = args.indexOf('--session'); +if (sessionIdx !== -1 && sessionIdx + 1 < args.length) { + const sessionName = args[sessionIdx + 1]; + // Only start session if no subcommand is provided + const otherArgs = args.filter((_: string, i: number) => i !== sessionIdx && i !== sessionIdx + 1); + const hasSubcommand = otherArgs.some((a: string) => !a.startsWith('-')); + if (!hasSubcommand) { + startNamedSession(sessionName).catch((err) => { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + }); + } else { + program.parse(); } +} else { + program.parse(); } -knownCommands.add('help'); - -const shortcutResult = resolveTeamShortcut(process.argv.slice(2), knownCommands, (name) => { - try { - return existsSync(join(homedir(), '.claude', 'teams', sanitizeTeamName(name))); - } catch { - return false; - } -}); - -if (shortcutResult.collisionWarning) { - console.warn(shortcutResult.collisionWarning); -} - -process.argv = [...process.argv.slice(0, 2), ...shortcutResult.args]; - -program.parse(); diff --git a/src/hooks/__tests__/branch-guard.test.ts b/src/hooks/__tests__/branch-guard.test.ts new file mode 100644 index 000000000..02e3f3fee --- /dev/null +++ b/src/hooks/__tests__/branch-guard.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'bun:test'; +import { branchGuard } from '../handlers/branch-guard.js'; +import type { HookPayload } from '../types.js'; + +function makePayload(command: string): HookPayload { + return { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command }, + }; +} + +describe('branch-guard', () => { + // ========================================================================= + // SHOULD BLOCK + // ========================================================================= + + describe('blocks push to main/master', () => { + const blocked = [ + 'git push origin main', + 'git push origin master', + 'git push -u origin main', + 'git push --set-upstream origin master', + 'git push origin main --tags', + 'git push origin HEAD:main', + 'git push origin feat/x:master', + ]; + + for (const cmd of blocked) { + test(`blocks: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeDefined(); + expect(result!.decision).toBe('deny'); + }); + } + }); + + describe('blocks gh pr create targeting main/master', () => { + const blocked = [ + 'gh pr create --base main --title "test"', + 'gh pr create --base master --title "test"', + 'gh pr create -B main --title "test"', + 'gh pr create -B master --title "test"', + 'gh pr create --base main --body "test" --title "test"', + ]; + + for (const cmd of blocked) { + test(`blocks: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeDefined(); + expect(result!.decision).toBe('deny'); + }); + } + }); + + describe('blocks gh pr create without --base', () => { + const blocked = ['gh pr create --title "test" --body "test"', 'gh pr create --title "no base"', 'gh pr create']; + + for (const cmd of blocked) { + test(`blocks: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeDefined(); + expect(result!.decision).toBe('deny'); + expect(result!.reason).toContain('--base'); + }); + } + }); + + describe('blocks gh pr merge', () => { + const blocked = ['gh pr merge', 'gh pr merge 123', 'gh pr merge --auto', 'gh pr merge 42 --squash']; + + for (const cmd of blocked) { + test(`blocks: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeDefined(); + expect(result!.decision).toBe('deny'); + expect(result!.reason).toContain('merge'); + }); + } + }); + + describe('blocks checkout main with chained mutation', () => { + const blocked = [ + 'git checkout main && git commit -m "test"', + 'git checkout master; git push origin master', + 'git checkout main && git add . && git commit -m "x"', + 'git checkout master && git merge feat/x', + 'git checkout main && git cherry-pick abc123', + 'git checkout main && git rebase feat/x', + ]; + + for (const cmd of blocked) { + test(`blocks: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeDefined(); + expect(result!.decision).toBe('deny'); + }); + } + }); + + // ========================================================================= + // SHOULD ALLOW + // ========================================================================= + + describe('allows legitimate commands', () => { + const allowed = [ + // Push to feature branches + 'git push origin feat/my-feature', + 'git push origin fix/bug-123', + 'git push -u origin feat/new-thing', + 'git push origin dev', + 'git push', + + // PR targeting dev + 'gh pr create --base dev --title "test" --body "test"', + 'gh pr create -B dev --title "test"', + 'gh pr create --base dev', + + // Read-only git commands mentioning main + 'git diff main...HEAD', + 'git log main..HEAD', + 'git log --oneline main', + 'git merge-base main HEAD', + 'git branch -a', + 'git fetch origin main', + + // Non-git commands + 'ls -la', + 'bun run build', + 'echo "hello"', + 'npm test', + + // Branch named main-something (word boundary) + 'git push origin feat/main-feature', + 'git checkout main-feature', + + // PR view/list (not merge or create) + 'gh pr list', + 'gh pr view 42', + 'gh pr checks 42', + ]; + + for (const cmd of allowed) { + test(`allows: ${cmd}`, async () => { + const result = await branchGuard(makePayload(cmd)); + expect(result).toBeUndefined(); + }); + } + }); + + // ========================================================================= + // EDGE CASES + // ========================================================================= + + describe('edge cases', () => { + test('handles empty command', async () => { + const result = await branchGuard({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: '' }, + }); + expect(result).toBeUndefined(); + }); + + test('handles missing tool_input', async () => { + const result = await branchGuard({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + }); + expect(result).toBeUndefined(); + }); + + test('handles missing command in tool_input', async () => { + const result = await branchGuard({ + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: {}, + }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/hooks/handlers/auto-spawn.ts b/src/hooks/handlers/auto-spawn.ts index 3bb4e8540..ee992654a 100644 --- a/src/hooks/handlers/auto-spawn.ts +++ b/src/hooks/handlers/auto-spawn.ts @@ -3,7 +3,12 @@ * * When an agent sends a message to a recipient that doesn't have a live * tmux pane, this handler attempts to respawn them from their saved - * template (created during the original `genie agent spawn`). + * template (created during the original `genie spawn`). + * + * Resolution order (directory-aware): + * 1. Check worker registry for live pane → skip if alive + * 2. Check agent directory for recipient identity + * 3. Check saved templates → spawn from template * * Priority: 20 (runs after identity-inject) */ @@ -30,6 +35,7 @@ export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> { // Lazy-import to avoid pulling heavy deps at dispatch startup const registryMod = await import('../../lib/agent-registry.js'); const tmuxMod = await import('../../lib/tmux.js'); + const directoryMod = await import('../../lib/agent-directory.js'); // Check if recipient has a live pane const agents = await registryMod.list(); @@ -40,19 +46,41 @@ export async function autoSpawn(payload: HookPayload): Promise<HandlerResult> { return; } + // Check agent directory for recipient identity (directory-first) + const dirEntry = await directoryMod.resolve(recipient); + // Check for a saved template to respawn from const templates = await registryMod.listTemplates(); - const template = templates.find((t) => (t.role === recipient || t.id === recipient) && t.team === teamName); + + // Build search candidates: recipient name + directory entry info + const searchNames = new Set([recipient]); + if (dirEntry) { + searchNames.add(dirEntry.entry.name); + if (dirEntry.entry.roles) { + for (const role of dirEntry.entry.roles) searchNames.add(role); + } + } + + const template = templates.find((t) => { + if (t.team !== teamName) return false; + return [...searchNames].some((q) => t.id === q || t.role === q); + }); if (!template) { + if (dirEntry) { + // Agent is known but has no spawn template — log for debugging + console.error( + `[genie-hook] Agent "${recipient}" is registered in directory but has no spawn template in team "${teamName}".`, + ); + } // No template — can't auto-spawn, let the message go through anyway // (CC will show "recipient not found" natively) return; } - // Respawn via genie agent spawn (non-blocking fork) + // Respawn via genie spawn (non-blocking fork) const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); - const args = ['agent', 'spawn', '--provider', template.provider, '--team', template.team]; + const args = ['spawn', '--provider', template.provider, '--team', template.team]; if (template.role) args.push('--role', template.role); if (template.skill) args.push('--skill', template.skill); if (template.cwd) args.push('--cwd', template.cwd); diff --git a/src/hooks/handlers/branch-guard.ts b/src/hooks/handlers/branch-guard.ts new file mode 100644 index 000000000..7dbf15ed8 --- /dev/null +++ b/src/hooks/handlers/branch-guard.ts @@ -0,0 +1,70 @@ +/** + * Branch Guard Handler — PreToolUse:Bash + * + * Blocks git/gh commands that target main/master branches. + * This is the hard enforcement layer for branch protection. + * + * Priority: 1 (runs FIRST, before all other handlers) + */ + +import type { HandlerResult, HookPayload } from '../types.js'; + +interface DenyPattern { + test: (command: string) => boolean; + reason: string; +} + +const DENY_PATTERNS: DenyPattern[] = [ + { + // git push origin main, git push -u origin master, git push --set-upstream origin main + // Must match main/master as a standalone arg (space-preceded), not inside a path like feat/main-feature + test: (cmd) => /git\s+push\b/i.test(cmd) && /(?:^|\s)(main|master)(?:\s|$)/.test(cmd), + reason: 'BLOCKED: Push to main/master is FORBIDDEN. Push to a feature branch and create a PR targeting dev.', + }, + { + // git push origin HEAD:main, git push origin feat:master + test: (cmd) => /git\s+push\b/.test(cmd) && /:(main|master)\b/.test(cmd), + reason: 'BLOCKED: Push refspec targeting main/master is FORBIDDEN.', + }, + { + // gh pr create --base main, gh pr create -B master + test: (cmd) => /gh\s+pr\s+create\b/.test(cmd) && /(--base|-B)\s+(main|master)\b/.test(cmd), + reason: 'BLOCKED: PRs must target dev, not main/master. Use: gh pr create --base dev', + }, + { + // gh pr create without --base (defaults to main) + test: (cmd) => /gh\s+pr\s+create\b/.test(cmd) && !/(--base|-B)\s+\S/.test(cmd), + reason: + 'BLOCKED: gh pr create requires explicit --base flag. Without it, GitHub defaults to main. Use: gh pr create --base dev', + }, + { + // gh pr merge (agents cannot merge PRs at all) + test: (cmd) => /gh\s+pr\s+merge\b/.test(cmd), + reason: 'BLOCKED: Agents may NOT merge PRs. Only humans merge via GitHub UI.', + }, + { + // git checkout main && git commit/merge/push/add/cherry-pick/rebase + test: (cmd) => + /git\s+checkout\s+(main|master)\s*[;&|]+\s*git\s+(commit|merge|cherry-pick|rebase|push|add)\b/.test(cmd), + reason: 'BLOCKED: Committing or mutating on main/master is FORBIDDEN. Work on feature branches.', + }, +]; + +export async function branchGuard(payload: HookPayload): Promise<HandlerResult> { + const input = payload.tool_input; + if (!input) return; + + const command = input.command as string | undefined; + if (!command) return; + + // Quick exit: if command doesn't mention git or gh, skip + if (!/\b(git|gh)\b/.test(command)) return; + + for (const pattern of DENY_PATTERNS) { + if (pattern.test(command)) { + return { decision: 'deny', reason: pattern.reason }; + } + } + + return; // implicit allow +} diff --git a/src/hooks/handlers/identity-inject.ts b/src/hooks/handlers/identity-inject.ts index e1a6932d3..e182366af 100644 --- a/src/hooks/handlers/identity-inject.ts +++ b/src/hooks/handlers/identity-inject.ts @@ -17,7 +17,7 @@ export async function identityInject(payload: HookPayload): Promise<HandlerResul const msgType = input.type as string | undefined; if (msgType !== 'message' && msgType !== 'broadcast') return; - // Resolve agent name from env (set by genie agent spawn via GENIE_AGENT_NAME) + // Resolve agent name from env (set by genie spawn via GENIE_AGENT_NAME) const agentName = process.env.GENIE_AGENT_NAME; if (!agentName) return; diff --git a/src/hooks/inject.ts b/src/hooks/inject.ts index 963a62346..f681a224e 100644 --- a/src/hooks/inject.ts +++ b/src/hooks/inject.ts @@ -1,7 +1,7 @@ /** * Hook Injection — writes CC hook config into team settings.json * - * Called during `genie agent spawn` to ensure every spawned agent + * Called during `genie spawn` to ensure every spawned agent * routes its hook events through `genie hook dispatch`. */ @@ -109,7 +109,7 @@ async function injectIntoFile(settingsPath: string): Promise<boolean> { /** * Inject hook dispatch config into a team's settings.json. - * Called automatically during `genie agent spawn`. + * Called automatically during `genie spawn`. */ export async function injectTeamHooks(teamName: string): Promise<boolean> { const path = teamSettingsPath(teamName); diff --git a/src/lib/agent-directory.test.ts b/src/lib/agent-directory.test.ts new file mode 100644 index 000000000..e3c497ee8 --- /dev/null +++ b/src/lib/agent-directory.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for Agent Directory module. + * + * Run with: bun test src/lib/agent-directory.test.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as directory from './agent-directory.js'; + +// ============================================================================ +// Test setup — use temp dir for GENIE_HOME +// ============================================================================ + +let testDir: string; +let agentDir: string; + +beforeEach(() => { + testDir = join(tmpdir(), `genie-dir-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + process.env.GENIE_HOME = testDir; + + // Create a fake agent dir with AGENTS.md + agentDir = join(testDir, 'test-agent-home'); + mkdirSync(agentDir, { recursive: true }); + writeFileSync(join(agentDir, 'AGENTS.md'), '# Test Agent\nYou are a test agent.'); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + process.env.GENIE_HOME = undefined; +}); + +// ============================================================================ +// add() +// ============================================================================ + +describe('add', () => { + test('persists entry to directory', async () => { + const entry = await directory.add({ + name: 'test-agent', + dir: agentDir, + promptMode: 'append', + }); + + expect(entry.name).toBe('test-agent'); + expect(entry.dir).toBe(agentDir); + expect(entry.promptMode).toBe('append'); + expect(entry.registeredAt).toBeTruthy(); + + // Verify persisted + const retrieved = await directory.get('test-agent'); + expect(retrieved).not.toBeNull(); + expect(retrieved!.name).toBe('test-agent'); + }); + + test('persists with all optional fields', async () => { + const entry = await directory.add({ + name: 'full-agent', + dir: agentDir, + repo: agentDir, // reuse dir as repo for test + promptMode: 'system', + model: 'opus', + roles: ['implementor', 'tester'], + }); + + expect(entry.repo).toBe(agentDir); + expect(entry.promptMode).toBe('system'); + expect(entry.model).toBe('opus'); + expect(entry.roles).toEqual(['implementor', 'tester']); + }); + + test('rejects duplicate name', async () => { + await directory.add({ name: 'agent1', dir: agentDir, promptMode: 'append' }); + await expect(directory.add({ name: 'agent1', dir: agentDir, promptMode: 'append' })).rejects.toThrow( + 'already exists', + ); + }); + + test('rejects missing directory', async () => { + await expect(directory.add({ name: 'ghost', dir: '/nonexistent/path', promptMode: 'append' })).rejects.toThrow( + 'does not exist', + ); + }); + + test('rejects directory without AGENTS.md', async () => { + const emptyDir = join(testDir, 'empty'); + mkdirSync(emptyDir, { recursive: true }); + await expect(directory.add({ name: 'no-agents', dir: emptyDir, promptMode: 'append' })).rejects.toThrow( + 'AGENTS.md not found', + ); + }); + + test('rejects empty name', async () => { + await expect(directory.add({ name: '', dir: agentDir, promptMode: 'append' })).rejects.toThrow('name is required'); + }); +}); + +// ============================================================================ +// rm() +// ============================================================================ + +describe('rm', () => { + test('removes existing entry', async () => { + await directory.add({ name: 'to-remove', dir: agentDir, promptMode: 'append' }); + const removed = await directory.rm('to-remove'); + expect(removed).toBe(true); + + const retrieved = await directory.get('to-remove'); + expect(retrieved).toBeNull(); + }); + + test('returns false for non-existent entry', async () => { + const removed = await directory.rm('nonexistent'); + expect(removed).toBe(false); + }); +}); + +// ============================================================================ +// resolve() +// ============================================================================ + +describe('resolve', () => { + test('resolves user directory entry', async () => { + await directory.add({ name: 'my-agent', dir: agentDir, promptMode: 'append' }); + const resolved = await directory.resolve('my-agent'); + expect(resolved).not.toBeNull(); + expect(resolved!.builtin).toBe(false); + expect(resolved!.entry.name).toBe('my-agent'); + }); + + test('resolves built-in role', async () => { + const resolved = await directory.resolve('implementor'); + expect(resolved).not.toBeNull(); + expect(resolved!.builtin).toBe(true); + expect(resolved!.entry.name).toBe('implementor'); + }); + + test('resolves built-in council member', async () => { + const resolved = await directory.resolve('council-architect'); + expect(resolved).not.toBeNull(); + expect(resolved!.builtin).toBe(true); + expect(resolved!.entry.name).toBe('council-architect'); + }); + + test('user entry overrides built-in', async () => { + await directory.add({ name: 'implementor', dir: agentDir, promptMode: 'system' }); + const resolved = await directory.resolve('implementor'); + expect(resolved).not.toBeNull(); + expect(resolved!.builtin).toBe(false); + expect(resolved!.entry.promptMode).toBe('system'); + }); + + test('returns null for unknown name', async () => { + const resolved = await directory.resolve('nonexistent'); + expect(resolved).toBeNull(); + }); +}); + +// ============================================================================ +// ls() +// ============================================================================ + +describe('ls', () => { + test('lists all user entries', async () => { + await directory.add({ name: 'agent-a', dir: agentDir, promptMode: 'append' }); + await directory.add({ name: 'agent-b', dir: agentDir, promptMode: 'system' }); + + const entries = await directory.ls(); + expect(entries.length).toBe(2); + expect(entries.map((e) => e.name).sort()).toEqual(['agent-a', 'agent-b']); + }); + + test('returns empty array when no entries', async () => { + const entries = await directory.ls(); + expect(entries).toEqual([]); + }); +}); + +// ============================================================================ +// edit() +// ============================================================================ + +describe('edit', () => { + test('updates model', async () => { + await directory.add({ name: 'editable', dir: agentDir, promptMode: 'append' }); + const updated = await directory.edit('editable', { model: 'opus' }); + expect(updated.model).toBe('opus'); + + // Verify persisted + const retrieved = await directory.get('editable'); + expect(retrieved!.model).toBe('opus'); + }); + + test('updates promptMode', async () => { + await directory.add({ name: 'editable', dir: agentDir, promptMode: 'append' }); + const updated = await directory.edit('editable', { promptMode: 'system' }); + expect(updated.promptMode).toBe('system'); + }); + + test('updates roles', async () => { + await directory.add({ name: 'editable', dir: agentDir, promptMode: 'append' }); + const updated = await directory.edit('editable', { roles: ['implementor', 'reviewer'] }); + expect(updated.roles).toEqual(['implementor', 'reviewer']); + }); + + test('rejects edit of non-existent entry', async () => { + await expect(directory.edit('nonexistent', { model: 'opus' })).rejects.toThrow('not found'); + }); + + test('validates new dir if provided', async () => { + await directory.add({ name: 'editable', dir: agentDir, promptMode: 'append' }); + await expect(directory.edit('editable', { dir: '/nonexistent/path' })).rejects.toThrow('does not exist'); + }); +}); + +// ============================================================================ +// loadIdentity() +// ============================================================================ + +describe('loadIdentity', () => { + test('returns path to AGENTS.md', async () => { + const entry = await directory.add({ name: 'identity-test', dir: agentDir, promptMode: 'append' }); + const identity = directory.loadIdentity(entry); + expect(identity).toBe(join(agentDir, 'AGENTS.md')); + }); + + test('returns null when AGENTS.md missing', async () => { + const emptyEntry = { + name: 'no-identity', + dir: '/nonexistent', + promptMode: 'append' as const, + registeredAt: new Date().toISOString(), + }; + const identity = directory.loadIdentity(emptyEntry); + expect(identity).toBeNull(); + }); +}); diff --git a/src/lib/agent-directory.ts b/src/lib/agent-directory.ts new file mode 100644 index 000000000..18fb4bfd3 --- /dev/null +++ b/src/lib/agent-directory.ts @@ -0,0 +1,328 @@ +/** + * Agent Directory — Persistent agent registry with CRUD operations. + * + * Stores agent identity entries at `~/.genie/agent-directory.json`. + * Each entry records the agent's folder (CWD + AGENTS.md source), + * optional repo, prompt mode, default model, and declared roles. + * + * Resolution order: user directory > built-in registry. + * Uses file-lock pattern (same as agent-registry.ts) for concurrent access. + */ + +import { existsSync } from 'node:fs'; +import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { BUILTIN_COUNCIL_MEMBERS, BUILTIN_ROLES, type BuiltinAgent } from './builtin-agents.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export type PromptMode = 'system' | 'append'; + +export interface DirectoryEntry { + /** Globally unique agent name. */ + name: string; + /** Agent folder — CWD at spawn, contains AGENTS.md. */ + dir: string; + /** Optional default git repo (overridden by team). */ + repo?: string; + /** Prompt injection mode: 'system' replaces CC default, 'append' adds to it. */ + promptMode: PromptMode; + /** Default model (e.g., 'sonnet', 'opus', 'codex'). */ + model?: string; + /** Built-in roles this agent can orchestrate. */ + roles?: string[]; + /** ISO timestamp of registration. */ + registeredAt: string; +} + +interface AgentDirectoryData { + entries: Record<string, DirectoryEntry>; + lastUpdated: string; +} + +/** Resolved agent — either a user directory entry or a built-in. */ +export interface ResolvedAgent { + /** The agent entry (user or synthetic built-in). */ + entry: DirectoryEntry; + /** Whether this came from the built-in registry. */ + builtin: boolean; +} + +// ============================================================================ +// Configuration +// ============================================================================ + +function getGlobalDir(): string { + return process.env.GENIE_HOME ?? join(homedir(), '.genie'); +} + +function getDirectoryFilePath(): string { + return join(getGlobalDir(), 'agent-directory.json'); +} + +// ============================================================================ +// File Locking — prevents concurrent load/modify/save races +// ============================================================================ + +const LOCK_TIMEOUT_MS = 5000; +const LOCK_RETRY_MS = 50; +const LOCK_STALE_MS = 10000; + +async function tryCleanStaleLock(lockPath: string): Promise<boolean> { + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { + try { + await unlink(lockPath); + } catch { + /* race with other cleanup */ + } + return true; + } + } catch { + return true; // lock gone, retry + } + return false; +} + +async function tryCreateLock(lockPath: string): Promise<(() => Promise<void>) | null> { + try { + const handle = await open(lockPath, 'wx'); + await handle.writeFile(String(process.pid)); + await handle.close(); + return async () => { + try { + await unlink(lockPath); + } catch { + /* already removed */ + } + }; + } catch (err) { + const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + if (errCode !== 'EEXIST') throw err; + return null; + } +} + +async function acquireLock(): Promise<() => Promise<void>> { + const lockPath = `${getDirectoryFilePath()}.lock`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (true) { + const release = await tryCreateLock(lockPath); + if (release) return release; + + const cleaned = await tryCleanStaleLock(lockPath); + if (cleaned) continue; + + if (Date.now() > deadline) { + try { + await unlink(lockPath); + } catch { + throw new Error(`Directory lock timeout: could not remove stale lock at ${lockPath}`); + } + continue; + } + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); + } +} + +// ============================================================================ +// Internal +// ============================================================================ + +async function loadDirectory(): Promise<AgentDirectoryData> { + try { + const content = await readFile(getDirectoryFilePath(), 'utf-8'); + return JSON.parse(content); + } catch { + return { entries: {}, lastUpdated: new Date().toISOString() }; + } +} + +async function saveDirectory(data: AgentDirectoryData): Promise<void> { + const filePath = getDirectoryFilePath(); + await mkdir(dirname(filePath), { recursive: true }); + data.lastUpdated = new Date().toISOString(); + await writeFile(filePath, JSON.stringify(data, null, 2)); +} + +async function withDirectory<T>(fn: (data: AgentDirectoryData) => T | Promise<T>): Promise<T> { + const release = await acquireLock(); + try { + const data = await loadDirectory(); + const result = await fn(data); + await saveDirectory(data); + return result; + } finally { + await release(); + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Add an agent to the directory. + * Validates that dir exists and contains AGENTS.md. + */ +export async function add(entry: Omit<DirectoryEntry, 'registeredAt'>): Promise<DirectoryEntry> { + if (!entry.name || entry.name.trim() === '') { + throw new Error('Agent name is required.'); + } + + if (!entry.dir || entry.dir.trim() === '') { + throw new Error('Agent directory (--dir) is required.'); + } + + if (!existsSync(entry.dir)) { + throw new Error(`Directory does not exist: ${entry.dir}`); + } + + const agentsPath = join(entry.dir, 'AGENTS.md'); + if (!existsSync(agentsPath)) { + throw new Error(`AGENTS.md not found in ${entry.dir}. Each agent directory must contain an AGENTS.md file.`); + } + + const full: DirectoryEntry = { + ...entry, + promptMode: entry.promptMode ?? 'append', + registeredAt: new Date().toISOString(), + }; + + await withDirectory((data) => { + if (data.entries[entry.name]) { + throw new Error(`Agent "${entry.name}" already exists. Use "genie dir edit" to update or "genie dir rm" first.`); + } + data.entries[entry.name] = full; + }); + + return full; +} + +/** + * Remove an agent from the directory. + */ +export async function rm(name: string): Promise<boolean> { + let removed = false; + await withDirectory((data) => { + if (data.entries[name]) { + delete data.entries[name]; + removed = true; + } + }); + return removed; +} + +/** + * Resolve an agent by name. + * Resolution order: user directory > built-in roles > built-in council members. + */ +export async function resolve(name: string): Promise<ResolvedAgent | null> { + // 1. Check user directory + const data = await loadDirectory(); + const userEntry = data.entries[name]; + if (userEntry) { + return { entry: userEntry, builtin: false }; + } + + // 2. Check built-in roles + const builtinRole = BUILTIN_ROLES.find((r: BuiltinAgent) => r.name === name); + if (builtinRole) { + return { entry: builtinToEntry(builtinRole), builtin: true }; + } + + // 3. Check built-in council members + const councilMember = BUILTIN_COUNCIL_MEMBERS.find((m: BuiltinAgent) => m.name === name); + if (councilMember) { + return { entry: builtinToEntry(councilMember), builtin: true }; + } + + return null; +} + +/** + * List all user-registered agents. + */ +export async function ls(): Promise<DirectoryEntry[]> { + const data = await loadDirectory(); + return Object.values(data.entries); +} + +/** + * Get a single entry by name (user directory only). + */ +export async function get(name: string): Promise<DirectoryEntry | null> { + const data = await loadDirectory(); + return data.entries[name] ?? null; +} + +/** + * Edit an existing agent entry. + * Only provided fields are updated. + */ +export async function edit( + name: string, + updates: Partial<Pick<DirectoryEntry, 'dir' | 'repo' | 'promptMode' | 'model' | 'roles'>>, +): Promise<DirectoryEntry> { + let updated: DirectoryEntry | null = null; + + await withDirectory((data) => { + const existing = data.entries[name]; + if (!existing) { + throw new Error(`Agent "${name}" not found in directory.`); + } + + // Validate new dir if provided + if (updates.dir) { + if (!existsSync(updates.dir)) { + throw new Error(`Directory does not exist: ${updates.dir}`); + } + const agentsPath = join(updates.dir, 'AGENTS.md'); + if (!existsSync(agentsPath)) { + throw new Error(`AGENTS.md not found in ${updates.dir}.`); + } + } + + Object.assign(existing, updates); + updated = existing; + }); + + if (!updated) { + throw new Error(`Agent "${name}" not found in directory.`); + } + return updated; +} + +/** + * Load the AGENTS.md identity file for an agent. + * Returns the file path (not content) for use with --system-prompt-file. + * Returns null if the file doesn't exist. + */ +export function loadIdentity(entry: DirectoryEntry): string | null { + const agentsPath = join(entry.dir, 'AGENTS.md'); + if (existsSync(agentsPath)) { + return agentsPath; + } + return null; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Convert a built-in agent definition to a synthetic DirectoryEntry. */ +function builtinToEntry(agent: BuiltinAgent): DirectoryEntry { + return { + name: agent.name, + dir: '', // built-ins don't have a home directory + promptMode: agent.promptMode ?? 'append', + model: agent.model, + roles: [], + registeredAt: '(built-in)', + }; +} diff --git a/src/lib/agent-registry.ts b/src/lib/agent-registry.ts index c9bea55aa..b0e7a9e1a 100644 --- a/src/lib/agent-registry.ts +++ b/src/lib/agent-registry.ts @@ -28,7 +28,7 @@ export type AgentState = export type TransportType = 'tmux' | 'inline'; export interface Agent { - /** Unique agent ID (usually matches taskId, e.g., "bd-42"). */ + /** Unique agent ID (usually matches taskId, e.g., "wish-42"). */ id: string; /** tmux pane ID (e.g., "%16"). */ paneId: string; @@ -36,9 +36,9 @@ export interface Agent { session: string; /** Path to git worktree, null if using shared repo. */ worktree: string | null; - /** Beads or local task ID this agent is bound to. */ + /** Task ID this agent is bound to. */ taskId?: string; - /** Task title from beads. */ + /** Task title. */ taskTitle?: string; /** Associated wish slug (if from decompose). */ wishSlug?: string; @@ -62,7 +62,7 @@ export interface Agent { role?: string; /** Custom agent name when multiple agents on same task. */ customName?: string; - /** Ordered list of sub-pane IDs from splits. Index 0 in subPanes = bd-42:1, etc. */ + /** Ordered list of sub-pane IDs from splits. Index 0 in subPanes = wish-42:1, etc. */ subPanes?: string[]; /** Provider used to launch this agent. */ provider?: ProviderName; @@ -260,17 +260,6 @@ export async function list(): Promise<Agent[]> { return Object.values(registry.workers); } -/** Update an agent's state. */ -export async function updateState(id: string, state: AgentState): Promise<void> { - await withRegistry((reg) => { - const agent = reg.workers[id]; - if (agent) { - agent.state = state; - agent.lastStateChange = new Date().toISOString(); - } - }); -} - /** Update multiple agent fields. */ export async function update(id: string, updates: Partial<Agent>): Promise<void> { await withRegistry((reg) => { @@ -284,7 +273,7 @@ export async function update(id: string, updates: Partial<Agent>): Promise<void> }); } -/** Find agent by tmux pane ID. */ +/** Find agent by tmux pane ID. @public - used via dynamic namespace import in msg.ts */ export async function findByPane(paneId: string): Promise<Agent | null> { const agents = await list(); const normalized = paneId.startsWith('%') ? paneId : `%${paneId}`; @@ -298,48 +287,12 @@ export async function findByWindow(windowId: string): Promise<Agent | null> { return agents.find((a) => a.windowId === normalizedId) ?? null; } -/** Find agent by beads task ID (returns first match for backwards compat). */ +/** Find agent by task ID (returns first match). */ export async function findByTask(taskId: string): Promise<Agent | null> { const agents = await list(); return agents.find((a) => a.taskId === taskId) ?? null; } -/** Find ALL agents for a beads task ID (supports N agents per task). */ -export async function findAllByTask(taskId: string): Promise<Agent[]> { - const agents = await list(); - return agents.filter((a) => a.taskId === taskId); -} - -/** Count agents for a task. */ -async function countByTask(taskId: string): Promise<number> { - const agents = await findAllByTask(taskId); - return agents.length; -} - -/** - * Generate a unique agent ID for a task (handles N agents per task). - * Returns taskId for first agent, taskId-2 for second, etc. - */ -export async function generateWorkerId(taskId: string, customName?: string): Promise<string> { - if (customName) { - return customName; - } - - const existingCount = await countByTask(taskId); - if (existingCount === 0) { - return taskId; - } - - // Find next available suffix - const agents = await list(); - let suffix = existingCount + 1; - while (agents.some((a) => a.id === `${taskId}-${suffix}`)) { - suffix++; - } - - return `${taskId}-${suffix}`; -} - /** Calculate elapsed time for an agent. */ export function getElapsedTime(agent: Agent): { ms: number; formatted: string } { const startTime = new Date(agent.startedAt).getTime(); @@ -360,16 +313,6 @@ export function getElapsedTime(agent: Agent): { ms: number; formatted: string } return { ms, formatted }; } -/** Format a Date as elapsed time string (e.g., "5m", "2h 30m"). */ -export function formatElapsed(date: Date): string { - const ms = Date.now() - date.getTime(); - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m`; - return '<1m'; -} - // ============================================================================ // Sub-Pane Helpers // ============================================================================ @@ -432,13 +375,6 @@ export async function saveTemplate(template: WorkerTemplate): Promise<void> { }); } -/** Remove a template by ID. */ -export async function removeTemplate(id: string): Promise<void> { - await withRegistry((reg) => { - delete reg.templates[id]; - }); -} - /** List all templates. */ export async function listTemplates(): Promise<WorkerTemplate[]> { const reg = await loadRegistry(); diff --git a/src/lib/auto-approve.test.ts b/src/lib/auto-approve.test.ts index 293789ba5..1437e8c03 100644 --- a/src/lib/auto-approve.test.ts +++ b/src/lib/auto-approve.test.ts @@ -985,7 +985,7 @@ describe('compound command smuggling prevention', () => { test('escalates command with $() substitution', () => { const request = makeRequest({ toolName: 'Bash', - toolInput: { command: 'echo $(cat /etc/passwd)' }, + toolInput: { command: 'echo $(id)' }, }); const decision = evaluateRequest(request, smuggleConfig); expect(decision.action).not.toBe('approve'); diff --git a/src/lib/auto-approve.ts b/src/lib/auto-approve.ts index 99f0051e3..881be3e03 100644 --- a/src/lib/auto-approve.ts +++ b/src/lib/auto-approve.ts @@ -339,31 +339,6 @@ export function parseWishAutoApprove(wishContent: string): WishAutoApproveOverri return result; } -/** - * Load complete auto-approve config including wish-level overrides - * - * @param repoPath - Path to the repository - * @param wishContent - Content of the wish.md file (optional) - * @param options - Loading options - * @returns Fully merged auto-approve configuration - */ -export async function loadFullAutoApproveConfig( - repoPath: string, - wishContent?: string, - options: LoadConfigOptions = {}, -): Promise<AutoApproveConfig> { - // Load global + repo config - let config = await loadAutoApproveConfig(repoPath, options); - - // Apply wish-level overrides if provided - if (wishContent) { - const wishOverride = parseWishAutoApprove(wishContent); - config = mergeConfigs(config, wishOverride); - } - - return config; -} - // ============================================================================ // Rule Matching (Group C) // ============================================================================ diff --git a/src/lib/beads-issues-jsonl.ts b/src/lib/beads-issues-jsonl.ts deleted file mode 100644 index cdcbfe5ba..000000000 --- a/src/lib/beads-issues-jsonl.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Beads issues.jsonl helpers (minimal, local-file only) - * - * Canonical file: <repo>/.beads/issues.jsonl - * - * This module intentionally does NOT call `bd` and does not implement any - * external ingestion. It only performs local JSONL upsert operations. - */ - -import { exec as execCb } from 'node:child_process'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { promisify } from 'node:util'; - -const execAsync = promisify(execCb); - -export interface BeadsIssueRecord { - id: string; - title: string; - status: 'open' | 'closed'; - source_repo: string; - wish_slug: string; - created_at: string; - updated_at: string; - depends_on: string[]; - // Allow forward-compatible fields - [k: string]: unknown; -} - -let _lastUtcNowMs = 0; - -/** - * RFC3339/ISO8601 UTC timestamp with a monotonic guarantee. - * - * Note: Date() only has millisecond resolution. When we write multiple records - * in the same tick (common in tests and fast CLIs), we still want updated_at - * to advance so downstream consumers can rely on it. - */ -function utcNowRfc3339(): string { - const ms = Date.now(); - const next = ms <= _lastUtcNowMs ? _lastUtcNowMs + 1 : ms; - _lastUtcNowMs = next; - return new Date(next).toISOString(); -} - -export async function ensureBeadsIssuesPath(repoPath: string): Promise<string> { - const issuesPath = join(repoPath, '.beads', 'issues.jsonl'); - await mkdir(dirname(issuesPath), { recursive: true }); - return issuesPath; -} - -function safeJsonParse(line: string): Record<string, unknown> | null { - try { - const parsed: unknown = JSON.parse(line); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record<string, unknown>; - } - return null; - } catch { - return null; - } -} - -/** - * Upsert a record by `id`. - * - Idempotent: repeated calls update the same line - * - Preserves other lines as-is - * - Best-effort preservation of original created_at when updating - */ -export async function upsertBeadsIssueJsonl( - issuesPath: string, - record: Omit<BeadsIssueRecord, 'created_at' | 'updated_at'> & { - created_at?: string; - updated_at?: string; - }, -): Promise<BeadsIssueRecord> { - const now = utcNowRfc3339(); - - let lines: string[] = []; - try { - const content = await readFile(issuesPath, 'utf-8'); - lines = content.split('\n'); - } catch { - // no file yet - } - - let existingCreatedAt: string | undefined; - - const newLines: string[] = []; - for (const line of lines) { - if (!line.trim()) continue; - const obj = safeJsonParse(line); - if (!obj || typeof obj !== 'object') { - // Preserve malformed lines (don't delete user data) - newLines.push(line); - continue; - } - if (obj.id === record.id) { - if (typeof obj.created_at === 'string') existingCreatedAt = obj.created_at; - // skip; will re-add updated line at end for simplicity - continue; - } - newLines.push(JSON.stringify(obj)); - } - - const final: BeadsIssueRecord = { - ...record, - created_at: record.created_at || existingCreatedAt || now, - updated_at: record.updated_at || now, - } as BeadsIssueRecord; - - newLines.push(JSON.stringify(final)); - - // Atomic write - await mkdir(dirname(issuesPath), { recursive: true }); - const tmp = `${issuesPath}.tmp-${process.pid}-${Date.now()}`; - await writeFile(tmp, `${newLines.join('\n')}\n`, 'utf-8'); - await rename(tmp, issuesPath); - - return final; -} - -/** - * Normalize source_repo to one of: - * - git:<remote-url> - * - git:file:<abs-path> - */ -export async function getSourceRepo(repoPath: string): Promise<string> { - // Prefer origin remote URL, normalized - try { - const { stdout } = await execAsync('git remote get-url origin', { cwd: repoPath }); - const url = stdout.trim(); - if (url) return `git:${normalizeGitRemote(url)}`; - } catch { - // ignore - } - return `git:file:${repoPath}`; -} - -function normalizeGitRemote(url: string): string { - // Trim and strip trailing .git or slash - let u = (url || '').trim(); - u = u.replace(/\s+/g, ''); - u = u.replace(/\.git$/i, ''); - u = u.replace(/\/$/, ''); - // Convert scp-like git@host:org/repo to ssh://git@host/org/repo - if (/^[\w.-]+@[\w.-]+:/.test(u) && !u.startsWith('ssh://')) { - const [userHost, path] = u.split(':', 2); - u = `ssh://${userHost}/${path}`; - } - return u; -} diff --git a/src/lib/beads-registry.ts b/src/lib/beads-registry.ts deleted file mode 100644 index 2c8230e89..000000000 --- a/src/lib/beads-registry.ts +++ /dev/null @@ -1,425 +0,0 @@ -/** - * Beads Registry - Worker state management via beads native commands - * - * Replaces JSON file-based worker-registry.ts with beads agent/slot system. - * Provides unified state tracking with Witness support and built-in cardinality. - * - * Environment: - * TERM_USE_BEADS_REGISTRY=false - Disable beads registry (fallback to JSON) - */ - -import { $ } from 'bun'; -import type { Agent, AgentState } from './agent-registry.js'; - -// ============================================================================ -// Configuration -// ============================================================================ - -const AGENT_LABEL = 'gt:agent'; - -/** - * Check if beads registry is enabled (default: true) - */ -export function isBeadsRegistryEnabled(): boolean { - return process.env.TERM_USE_BEADS_REGISTRY !== 'false'; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Run bd command and parse output - */ -async function runBd(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { - try { - const result = await $`bd ${args}`.quiet(); - return { - stdout: result.stdout.toString().trim(), - stderr: result.stderr.toString().trim(), - exitCode: 0, - }; - } catch (error) { - const shellErr = error as { stdout?: Buffer; stderr?: Buffer; exitCode?: number }; - return { - stdout: shellErr.stdout?.toString().trim() || '', - stderr: shellErr.stderr?.toString().trim() || '', - exitCode: shellErr.exitCode || 1, - }; - } -} - -/** - * Parse JSON from bd command, handling potential errors - */ -function parseJson<T>(output: string): T | null { - if (!output) return null; - try { - return JSON.parse(output); - } catch { - return null; - } -} - -// ============================================================================ -// Agent Bead Management -// ============================================================================ - -interface AgentBead { - id: string; - title: string; - type: string; - labels: string[]; - state?: string; - metadata?: Record<string, unknown>; -} - -/** - * Create a new agent bead for a worker - */ -async function createAgent( - workerId: string, - metadata: { - paneId: string; - session: string; - worktree: string | null; - repoPath: string; - taskId: string; - taskTitle?: string; - claudeSessionId?: string; - }, -): Promise<string> { - // Create agent bead with metadata - const title = `Worker: ${workerId}`; - const { stdout, exitCode } = await runBd([ - 'create', - `--title=${title}`, - '--type=agent', - `--label=${AGENT_LABEL}`, - `--label=worker:${workerId}`, - '--json', - ]); - - if (exitCode !== 0) { - throw new Error(`Failed to create agent bead: ${stdout}`); - } - - const created = parseJson<{ id: string }>(stdout); - if (!created?.id) { - throw new Error('Failed to parse created agent bead'); - } - - // Store worker metadata in agent - const metadataJson = JSON.stringify({ - paneId: metadata.paneId, - session: metadata.session, - worktree: metadata.worktree, - repoPath: metadata.repoPath, - taskId: metadata.taskId, - taskTitle: metadata.taskTitle, - claudeSessionId: metadata.claudeSessionId, - startedAt: new Date().toISOString(), - }); - - await runBd(['update', created.id, `--metadata=${metadataJson}`]); - - return created.id; -} - -/** - * Ensure an agent bead exists for a worker, creating if needed - */ -export async function ensureAgent( - workerId: string, - metadata: { - paneId: string; - session: string; - worktree: string | null; - repoPath: string; - taskId: string; - taskTitle?: string; - claudeSessionId?: string; - }, -): Promise<string> { - // Check if agent already exists - const existing = await findAgentByWorkerId(workerId); - if (existing) { - return existing.id; - } - - return createAgent(workerId, metadata); -} - -/** - * Find agent bead by worker ID - */ -async function findAgentByWorkerId(workerId: string): Promise<AgentBead | null> { - const { stdout, exitCode } = await runBd(['list', `--label=${AGENT_LABEL}`, `--label=worker:${workerId}`, '--json']); - - if (exitCode !== 0 || !stdout) return null; - - const agents = parseJson<AgentBead[]>(stdout); - return agents?.[0] || null; -} - -/** - * Delete an agent bead - */ -export async function deleteAgent(agentIdOrWorkerId: string): Promise<boolean> { - // Try direct ID first - let { exitCode } = await runBd(['delete', agentIdOrWorkerId]); - if (exitCode === 0) return true; - - // Try finding by worker ID - const agent = await findAgentByWorkerId(agentIdOrWorkerId); - if (agent) { - ({ exitCode } = await runBd(['delete', agent.id])); - return exitCode === 0; - } - - return false; -} - -// ============================================================================ -// Agent State Management -// ============================================================================ - -/** - * Map our WorkerState to beads agent states - */ -function mapToBeadsState(state: AgentState): string { - switch (state) { - case 'spawning': - return 'spawning'; - case 'working': - return 'working'; - case 'idle': - return 'idle'; - case 'permission': - return 'blocked'; - case 'question': - return 'blocked'; - case 'done': - return 'done'; - case 'error': - return 'error'; - default: - return 'unknown'; - } -} - -/** - * Map beads agent state to WorkerState - */ -function mapFromBeadsState(beadsState: string): AgentState { - switch (beadsState) { - case 'spawning': - return 'spawning'; - case 'working': - return 'working'; - case 'idle': - return 'idle'; - case 'blocked': - return 'permission'; // Could be permission or question - case 'done': - return 'done'; - case 'error': - return 'error'; - default: - return 'idle'; - } -} - -/** - * Set agent state - */ -export async function setAgentState(workerId: string, state: AgentState): Promise<void> { - const agent = await findAgentByWorkerId(workerId); - if (!agent) { - throw new Error(`Agent not found for worker ${workerId}`); - } - - const beadsState = mapToBeadsState(state); - const { exitCode, stderr } = await runBd(['agent', 'state', agent.id, beadsState]); - - if (exitCode !== 0) { - throw new Error(`Failed to set agent state: ${stderr}`); - } -} - -async function heartbeat(workerId: string): Promise<void> { - const agent = await findAgentByWorkerId(workerId); - if (!agent) return; - await runBd(['agent', 'heartbeat', agent.id]); -} - -// ============================================================================ -// Work Binding (Slots) -// ============================================================================ - -/** - * Bind work (task) to an agent - */ -export async function bindWork(workerId: string, taskId: string): Promise<void> { - const agent = await findAgentByWorkerId(workerId); - if (!agent) { - throw new Error(`Agent not found for worker ${workerId}`); - } - - const { exitCode, stderr } = await runBd(['slot', 'set', agent.id, 'hook', taskId]); - - if (exitCode !== 0) { - throw new Error(`Failed to bind work: ${stderr}`); - } -} - -/** - * Unbind work from an agent - */ -export async function unbindWork(workerId: string): Promise<void> { - const agent = await findAgentByWorkerId(workerId); - if (!agent) return; // Silently ignore if agent doesn't exist - - await runBd(['slot', 'clear', agent.id, 'hook']); -} - -// ============================================================================ -// Worker Query Functions -// ============================================================================ - -interface AgentMetadata { - paneId: string; - session: string; - worktree: string | null; - repoPath: string; - taskId: string; - taskTitle?: string; - startedAt: string; - wishSlug?: string; - groupNumber?: number; - claudeSessionId?: string; -} - -/** - * Convert agent bead to Worker interface - */ -function agentToWorker(agent: AgentBead, metadata: AgentMetadata): Agent { - return { - id: metadata.taskId, // Worker ID matches task ID - paneId: metadata.paneId, - session: metadata.session, - worktree: metadata.worktree, - taskId: metadata.taskId, - taskTitle: metadata.taskTitle, - wishSlug: metadata.wishSlug, - groupNumber: metadata.groupNumber, - startedAt: metadata.startedAt, - state: mapFromBeadsState(agent.state || 'idle'), - lastStateChange: new Date().toISOString(), // Would need to track this in beads - repoPath: metadata.repoPath, - claudeSessionId: metadata.claudeSessionId, - }; -} - -/** - * Get a worker by ID - */ -async function getWorker(workerId: string): Promise<Agent | null> { - const agent = await findAgentByWorkerId(workerId); - if (!agent) return null; - - // Get full agent details with metadata - const { stdout, exitCode } = await runBd(['show', agent.id, '--json']); - if (exitCode !== 0 || !stdout) return null; - - const fullAgent = parseJson<AgentBead & { metadata?: AgentMetadata }>(stdout); - if (!fullAgent?.metadata) return null; - - return agentToWorker(fullAgent, fullAgent.metadata); -} - -/** - * Find worker by task ID - */ -export async function findByTask(taskId: string): Promise<Agent | null> { - // Worker ID typically matches task ID - return getWorker(taskId); -} - -// ============================================================================ -// Daemon Management -// ============================================================================ - -interface DaemonStatus { - running: boolean; - pid?: number; - lastSync?: string; - autoCommit?: boolean; - autoPush?: boolean; -} - -/** - * Check if beads daemon is running - */ -export async function checkDaemonStatus(): Promise<DaemonStatus> { - const { stdout, exitCode } = await runBd(['daemon', 'status', '--json']); - - if (exitCode !== 0) { - return { running: false }; - } - - const status = parseJson<DaemonStatus>(stdout); - return status || { running: false }; -} - -/** - * Start beads daemon - */ -export async function startDaemon(options?: { autoCommit?: boolean; autoPush?: boolean }): Promise<boolean> { - const args = ['daemon', 'start']; - if (options?.autoCommit) args.push('--auto-commit'); - if (options?.autoPush) args.push('--auto-push'); - - const { exitCode } = await runBd(args); - return exitCode === 0; -} - -/** - * Stop beads daemon - */ -export async function stopDaemon(): Promise<boolean> { - const { exitCode } = await runBd(['daemon', 'stop']); - return exitCode === 0; -} - -// ============================================================================ -// Worktree Management (via bd worktree) -// ============================================================================ - -/** - * Remove worktree via beads - */ -export async function removeWorktree(name: string): Promise<boolean> { - const { exitCode } = await runBd(['worktree', 'remove', name]); - return exitCode === 0; -} - -// ============================================================================ -// Convenience Functions -// ============================================================================ - -/** - * Unregister a worker — unbinds work and deletes the agent bead. - */ -export async function unregister(workerId: string): Promise<void> { - await unbindWork(workerId); - await deleteAgent(workerId); -} - -/** - * Update worker state and send heartbeat. - */ -export async function updateState(workerId: string, state: AgentState): Promise<void> { - await setAgentState(workerId, state); - await heartbeat(workerId); -} diff --git a/src/lib/builtin-agents.test.ts b/src/lib/builtin-agents.test.ts new file mode 100644 index 000000000..c64401c7a --- /dev/null +++ b/src/lib/builtin-agents.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for Built-in Agents registry. + * + * Run with: bun test src/lib/builtin-agents.test.ts + */ + +import { describe, expect, test } from 'bun:test'; +import { + ALL_BUILTINS, + BUILTIN_COUNCIL_MEMBERS, + BUILTIN_ROLES, + getBuiltin, + listCouncilNames, + listRoleNames, +} from './builtin-agents.js'; + +describe('BUILTIN_ROLES', () => { + test('has 10 built-in roles', () => { + expect(BUILTIN_ROLES.length).toBe(10); + }); + + test('all roles have required fields', () => { + for (const role of BUILTIN_ROLES) { + expect(role.name).toBeTruthy(); + expect(role.description).toBeTruthy(); + expect(role.systemPrompt).toBeTruthy(); + expect(role.category).toBe('role'); + } + }); + + test('role names are unique', () => { + const names = BUILTIN_ROLES.map((r) => r.name); + expect(new Set(names).size).toBe(names.length); + }); + + test('contains expected roles', () => { + const names = listRoleNames(); + expect(names).toContain('implementor'); + expect(names).toContain('tester'); + expect(names).toContain('reviewer'); + expect(names).toContain('debugger'); + expect(names).toContain('verifier'); + expect(names).toContain('investigator'); + expect(names).toContain('reproducer'); + expect(names).toContain('dreamer'); + expect(names).toContain('critic'); + expect(names).toContain('security'); + }); +}); + +describe('BUILTIN_COUNCIL_MEMBERS', () => { + test('has 10 council members', () => { + expect(BUILTIN_COUNCIL_MEMBERS.length).toBe(10); + }); + + test('all council members have required fields', () => { + for (const member of BUILTIN_COUNCIL_MEMBERS) { + expect(member.name).toBeTruthy(); + expect(member.description).toBeTruthy(); + expect(member.systemPrompt).toBeTruthy(); + expect(member.model).toBeTruthy(); + expect(member.category).toBe('council'); + } + }); + + test('all council names start with council-', () => { + for (const member of BUILTIN_COUNCIL_MEMBERS) { + expect(member.name.startsWith('council-')).toBe(true); + } + }); + + test('council member names are unique', () => { + const names = BUILTIN_COUNCIL_MEMBERS.map((m) => m.name); + expect(new Set(names).size).toBe(names.length); + }); + + test('contains expected members', () => { + const names = listCouncilNames(); + expect(names).toContain('council-questioner'); + expect(names).toContain('council-benchmarker'); + expect(names).toContain('council-simplifier'); + expect(names).toContain('council-sentinel'); + expect(names).toContain('council-ergonomist'); + expect(names).toContain('council-architect'); + expect(names).toContain('council-operator'); + expect(names).toContain('council-deployer'); + expect(names).toContain('council-measurer'); + expect(names).toContain('council-tracer'); + }); + + test('sentinel and architect use opus model', () => { + const sentinel = getBuiltin('council-sentinel'); + const architect = getBuiltin('council-architect'); + expect(sentinel?.model).toBe('opus'); + expect(architect?.model).toBe('opus'); + }); + + test('other council members use sonnet model', () => { + const sonnetMembers = BUILTIN_COUNCIL_MEMBERS.filter( + (m) => m.name !== 'council-sentinel' && m.name !== 'council-architect', + ); + for (const member of sonnetMembers) { + expect(member.model).toBe('sonnet'); + } + }); +}); + +describe('ALL_BUILTINS', () => { + test('has 20 total built-in agents', () => { + expect(ALL_BUILTINS.length).toBe(20); + }); + + test('names are globally unique across roles and council', () => { + const names = ALL_BUILTINS.map((a) => a.name); + expect(new Set(names).size).toBe(names.length); + }); +}); + +describe('getBuiltin', () => { + test('finds a role by name', () => { + const result = getBuiltin('implementor'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('implementor'); + expect(result!.category).toBe('role'); + }); + + test('finds a council member by name', () => { + const result = getBuiltin('council-architect'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('council-architect'); + expect(result!.category).toBe('council'); + }); + + test('returns null for unknown name', () => { + expect(getBuiltin('nonexistent')).toBeNull(); + }); +}); diff --git a/src/lib/builtin-agents.ts b/src/lib/builtin-agents.ts new file mode 100644 index 000000000..deea09963 --- /dev/null +++ b/src/lib/builtin-agents.ts @@ -0,0 +1,258 @@ +/** + * Built-in Agents — Roles and council members that ship with genie. + * + * Built-in roles are ephemeral capabilities (implementor, tester, etc.) + * spawned on demand. They have no persistent identity or memory. + * + * Council members are specialized review perspectives with default + * models and lens prompts, sourced from the council skill. + * + * Resolution: user directory entries override built-ins of the same name. + */ + +import type { PromptMode } from './agent-directory.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BuiltinAgent { + /** Agent name (globally unique within built-ins). */ + name: string; + /** Short description of what this agent does. */ + description: string; + /** System prompt defining purpose, constraints, and output expectations. */ + systemPrompt: string; + /** Default model for this agent. */ + model?: string; + /** Prompt mode: 'system' replaces CC default, 'append' preserves it. */ + promptMode?: PromptMode; + /** Category for display grouping. */ + category: 'role' | 'council'; +} + +// ============================================================================ +// Built-in Roles +// ============================================================================ + +export const BUILTIN_ROLES: BuiltinAgent[] = [ + { + name: 'implementor', + description: 'Implements features and fixes bugs', + category: 'role', + systemPrompt: `You are an implementor agent. Your job is to write production-quality code that fulfills the requirements given to you. Focus on correctness, simplicity, and maintainability. Follow the existing codebase conventions. Write tests when the task includes test criteria. Signal completion to your leader when done. + +Do not review your own code — that is someone else's job. Do not refactor unrelated code. Stay focused on the assigned deliverables.`, + }, + { + name: 'tester', + description: 'Writes and runs tests', + category: 'role', + systemPrompt: `You are a tester agent. Your job is to write comprehensive tests that validate the implementation meets its acceptance criteria. Focus on edge cases, error paths, and integration boundaries. Use the project's existing test framework and conventions. + +Run all tests you write and ensure they pass. Report any failures with reproduction steps. Do not fix implementation bugs — report them to the implementor.`, + }, + { + name: 'reviewer', + description: 'Reviews code and provides feedback', + category: 'role', + systemPrompt: `You are a reviewer agent. Your job is to review code changes for correctness, security, performance, and adherence to project conventions. Provide actionable feedback with specific file and line references. + +Categorize findings by severity: BLOCKER (must fix), WARNING (should fix), NIT (optional). Focus on real issues, not style preferences. Approve when the code is production-ready.`, + }, + { + name: 'debugger', + description: 'Diagnoses and fixes bugs', + category: 'role', + systemPrompt: `You are a debugger agent. Your job is to diagnose the root cause of bugs using systematic investigation — reproduce, isolate, trace, and fix. Never apply speculative patches. Always understand the root cause before writing a fix. + +Document your investigation trail: what you checked, what you found, and why the fix is correct. Write a regression test for every fix.`, + }, + { + name: 'verifier', + description: 'Verifies fixes and writes regression tests', + category: 'role', + systemPrompt: `You are a verifier agent. Your job is to verify that bug fixes actually resolve the reported issue and don't introduce regressions. Reproduce the original bug, apply the fix, and confirm resolution. + +Write regression tests that would catch the bug if it recurred. Test related functionality for collateral damage. Report PASS or FAIL with evidence.`, + }, + { + name: 'investigator', + description: 'Investigates root causes', + category: 'role', + systemPrompt: `You are an investigator agent. Your job is to trace complex issues to their root cause through systematic analysis. Read logs, examine state, follow data flows, and build a causal chain from symptom to source. + +Produce a clear investigation report: timeline, evidence, root cause, and recommended fix approach. Do not implement fixes — hand off to a debugger or implementor.`, + }, + { + name: 'reproducer', + description: 'Creates minimal reproductions', + category: 'role', + systemPrompt: `You are a reproducer agent. Your job is to create minimal, reliable reproductions of reported bugs. Strip away unrelated complexity until you have the smallest test case that demonstrates the issue. + +Document exact reproduction steps, expected vs actual behavior, and environment requirements. A good reproduction makes the fix obvious.`, + }, + { + name: 'dreamer', + description: 'Generates ideas and explores possibilities', + category: 'role', + systemPrompt: `You are a dreamer agent. Your job is to explore solution spaces, generate creative approaches, and think beyond conventional patterns. Propose multiple distinct options with tradeoffs for each. + +Be bold but grounded — wild ideas are welcome if you can explain the path from here to there. Evaluate feasibility honestly. Your output feeds into design decisions, not direct implementation.`, + }, + { + name: 'critic', + description: 'Evaluates and refines ideas', + category: 'role', + systemPrompt: `You are a critic agent. Your job is to stress-test ideas, plans, and designs by finding weaknesses, blind spots, and unexamined assumptions. Be constructively adversarial — your goal is to make the final design stronger. + +For each concern, propose a mitigation or alternative. Rank concerns by severity and likelihood. A good critique makes the path forward clearer, not muddier.`, + }, + { + name: 'security', + description: 'Security-focused review', + category: 'role', + systemPrompt: `You are a security agent. Your job is to review code and architecture for security vulnerabilities — injection, authentication/authorization flaws, data exposure, dependency risks, and OWASP Top 10 issues. + +Categorize findings by severity (CRITICAL, HIGH, MEDIUM, LOW) with specific remediation steps. Check for secrets in code, insecure defaults, and missing input validation at system boundaries.`, + }, +]; + +// ============================================================================ +// Built-in Council Members +// ============================================================================ + +export const BUILTIN_COUNCIL_MEMBERS: BuiltinAgent[] = [ + { + name: 'council-questioner', + description: 'Challenge assumptions, seek foundational simplicity', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Questioner on the council. Your lens: "Why? Is there a simpler way?" + +Challenge every assumption. Ask the questions nobody else is asking. Demand justification for complexity. If something can be removed without loss, it should be. Your role is to ensure the team doesn't build the wrong thing elegantly. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-benchmarker', + description: 'Performance evidence, benchmark-driven analysis', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Benchmarker on the council. Your lens: "Show me the benchmarks." + +Demand measured evidence for performance claims. Reject "should be fast" without numbers. Identify hot paths, allocation patterns, and scaling bottlenecks. If there's no benchmark, propose one. Performance matters — but only where it's measured. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-simplifier', + description: 'Complexity reduction, minimalist philosophy', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Simplifier on the council. Your lens: "Delete code. Ship features." + +Every abstraction has a cost. Every config option is a decision someone has to make. Fight for deletion over addition. Three similar lines of code are better than a premature abstraction. If it can be hardcoded, hardcode it. Complexity is the enemy. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-sentinel', + description: 'Security oversight, blast radius assessment', + category: 'council', + model: 'opus', + systemPrompt: `You are the Sentinel on the council. Your lens: "Where are the secrets? What's the blast radius?" + +Audit for secrets management, authentication boundaries, and authorization gaps. Assess the blast radius of every change. Check for injection surfaces, data exposure, and dependency vulnerabilities. Security is not a feature — it's a constraint on every feature. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-ergonomist', + description: 'Developer experience, API usability', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Ergonomist on the council. Your lens: "If you need to read the docs, the API failed." + +Evaluate developer experience: error messages, API clarity, naming, defaults, and the pit of success. Good DX means the right thing is the easy thing. Bad error messages are bugs. Confusing APIs create bugs. Optimize for the developer who will use this at 2am. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-architect', + description: 'Systems thinking, backwards compatibility', + category: 'council', + model: 'opus', + systemPrompt: `You are the Architect on the council. Your lens: "Talk is cheap. Show me the code." + +Think in systems: data flow, failure domains, coupling, and evolution. Assess backwards compatibility and migration paths. Identify architectural decisions that are hard to reverse. Prefer boring technology that works over novel technology that might. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-operator', + description: 'Operations reality, infrastructure readiness', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Operator on the council. Your lens: "No one wants to run your code." + +Evaluate operational readiness: can this be deployed without a PhD? Are there health checks, graceful shutdown, and configuration that doesn't require recompilation? Think about the on-call engineer at 3am. If it's hard to operate, it's not done. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-deployer', + description: 'Zero-config deployment, CI/CD optimization', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Deployer on the council. Your lens: "Zero-config with infinite scale." + +Evaluate deployment story: can this ship with zero manual steps? Are there preview environments? Is rollback trivial? Fight for deployment simplicity — every manual step is a future incident. CI/CD is not optional, it's table stakes. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-measurer', + description: 'Observability, profiling, metrics philosophy', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Measurer on the council. Your lens: "Measure, don't guess." + +Demand observability: structured logging, meaningful metrics, and distributed tracing. If you can't measure it, you can't improve it. Reject changes that reduce visibility into system behavior. Every significant code path should be instrumentable. + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, + { + name: 'council-tracer', + description: 'Production debugging, high-cardinality observability', + category: 'council', + model: 'sonnet', + systemPrompt: `You are the Tracer on the council. Your lens: "You will debug this in production." + +Evaluate debuggability: when this breaks in production, can you find the root cause? Are there correlation IDs, structured logs with context, and meaningful error messages? Think about the debug loop — how many steps from "something's wrong" to "found it." + +Vote APPROVE, REJECT, or MODIFY with a clear rationale.`, + }, +]; + +// ============================================================================ +// Lookup Helpers +// ============================================================================ + +/** All built-in agents (roles + council). */ +export const ALL_BUILTINS: BuiltinAgent[] = [...BUILTIN_ROLES, ...BUILTIN_COUNCIL_MEMBERS]; + +/** Get a built-in agent by name. */ +export function getBuiltin(name: string): BuiltinAgent | null { + return ALL_BUILTINS.find((a) => a.name === name) ?? null; +} + +/** List all built-in role names. */ +export function listRoleNames(): string[] { + return BUILTIN_ROLES.map((r) => r.name); +} + +/** List all built-in council member names. */ +export function listCouncilNames(): string[] { + return BUILTIN_COUNCIL_MEMBERS.map((m) => m.name); +} diff --git a/src/lib/claude-logs.ts b/src/lib/claude-logs.ts index bb088198a..522a15238 100644 --- a/src/lib/claude-logs.ts +++ b/src/lib/claude-logs.ts @@ -347,68 +347,6 @@ export async function readLogFile(logPath: string): Promise<ClaudeLogEntry[]> { * @param pollIntervalMs - How often to check for new content (default 500ms) * @returns Cleanup function to stop tailing */ -export async function tailLogFile( - logPath: string, - onEntry: (entry: ClaudeLogEntry) => void, - pollIntervalMs = 500, -): Promise<() => void> { - let lastSize = 0; - let running = true; - let pendingBuffer = ''; - - try { - const stats = await stat(logPath); - lastSize = stats.size; - } catch { - // File doesn't exist yet, start from 0 - } - - async function readNewContent(fromOffset: number): Promise<string> { - const { createReadStream } = await import('node:fs'); - const stream = createReadStream(logPath, { start: fromOffset, encoding: 'utf-8' }); - let content = ''; - for await (const chunk of stream) { - content += chunk; - } - return content; - } - - function processLines(newContent: string): void { - const content = pendingBuffer + newContent; - const lines = content.split('\n'); - pendingBuffer = lines.pop() || ''; - for (const line of lines) { - const entry = parseLogEntry(line); - if (entry) onEntry(entry); - } - } - - const poll = async () => { - if (!running) return; - - try { - const currentSize = (await stat(logPath)).size; - if (currentSize > lastSize) { - processLines(await readNewContent(lastSize)); - lastSize = currentSize; - } - } catch { - // File might have been deleted or rotated - } - - if (running) { - setTimeout(poll, pollIntervalMs); - } - }; - - // Start polling - poll(); - - // Return cleanup function - return () => { - running = false; - }; -} // ============================================================================ // High-Level Discovery Functions diff --git a/src/lib/genie-config.ts b/src/lib/genie-config.ts index 6243bbaaa..9d5eb6195 100644 --- a/src/lib/genie-config.ts +++ b/src/lib/genie-config.ts @@ -2,12 +2,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { - type CouncilPreset, type GenieConfig, GenieConfigSchema, type ShortcutsConfig, type TerminalConfig, - type WorkerProfile, } from '../types/genie-config.js'; const GENIE_DIR = join(homedir(), '.genie'); @@ -128,14 +126,6 @@ export function getTerminalConfig(): TerminalConfig { return config.terminal; } -/** - * Get session name from config - */ -export function getSessionName(): string { - const config = loadGenieConfigSync(); - return config.session.name; -} - /** * Check if setup has been completed */ @@ -176,64 +166,6 @@ export async function updateShortcutsConfig(partial: Partial<ShortcutsConfig>): // Worker Profile helpers // ============================================================================ -/** - * Get a worker profile by name - * @param config - The genie config object - * @param profileName - Name of the profile to get - * @returns The WorkerProfile if found, undefined otherwise - */ -export function getWorkerProfile(config: GenieConfig, profileName: string): WorkerProfile | undefined { - return config.workerProfiles?.[profileName]; -} - -/** - * Get the default worker profile - * @param config - The genie config object - * @returns The default WorkerProfile if configured, undefined otherwise - */ -export function getDefaultWorkerProfile(config: GenieConfig): WorkerProfile | undefined { - if (!config.defaultWorkerProfile) { - return undefined; - } - return getWorkerProfile(config, config.defaultWorkerProfile); -} - // ============================================================================ // Council preset helpers // ============================================================================ - -/** - * Get a council preset by name - * @param config - The genie config object - * @param presetName - The preset name to look up - * @returns The CouncilPreset if found, undefined otherwise - */ -export function getCouncilPreset(config: GenieConfig, presetName: string): CouncilPreset | undefined { - return config.councilPresets?.[presetName]; -} - -/** - * Get the default council preset - * @param config - The genie config object - * @returns The default CouncilPreset if configured, undefined otherwise - */ -export function getDefaultCouncilPreset(config: GenieConfig): CouncilPreset | undefined { - if (!config.defaultCouncilPreset) { - return undefined; - } - return getCouncilPreset(config, config.defaultCouncilPreset); -} - -/** - * Get the fallback council preset when none is configured - * Uses existing worker profiles with sensible defaults - */ -export function getFallbackCouncilPreset(config: GenieConfig): CouncilPreset { - // Use default worker profile for both if available, otherwise 'coding-fast' - const defaultProfile = config.defaultWorkerProfile || 'coding-fast'; - return { - left: defaultProfile, - right: defaultProfile, - skill: 'council', - }; -} diff --git a/src/lib/idle-timeout.ts b/src/lib/idle-timeout.ts index 15bb70c92..16a519e19 100644 --- a/src/lib/idle-timeout.ts +++ b/src/lib/idle-timeout.ts @@ -129,42 +129,3 @@ export async function checkIdleWorkers(deps: IdleDeps = defaultDeps): Promise<st return suspended; } - -/** - * Run the watchdog loop — polls every WATCHDOG_POLL_INTERVAL_MS. - * Designed to run in a background tmux pane via scripts/watchdog.ts. - * - * @param signal — AbortSignal to stop the loop (for testing). - */ -export async function runWatchdogLoop(signal?: AbortSignal): Promise<void> { - const poll = async () => { - try { - const suspended = await checkIdleWorkers(); - if (suspended.length > 0) { - console.log(`[watchdog] Suspended ${suspended.length} idle worker(s): ${suspended.join(', ')}`); - } - } catch (err) { - console.error('[watchdog] Error checking idle workers:', err); - } - }; - - // Initial check - await poll(); - - // Poll loop - while (!signal?.aborted) { - await new Promise<void>((resolve) => { - const timer = setTimeout(resolve, WATCHDOG_POLL_INTERVAL_MS); - signal?.addEventListener( - 'abort', - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); - }); - if (signal?.aborted) break; - await poll(); - } -} diff --git a/src/lib/local-tasks.test.ts b/src/lib/local-tasks.test.ts index 315552df7..5d09b1982 100644 --- a/src/lib/local-tasks.test.ts +++ b/src/lib/local-tasks.test.ts @@ -21,12 +21,17 @@ import { } from './local-tasks.js'; let tempDir: string; +let savedEnv: string | undefined; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'local-tasks-test-')); + savedEnv = process.env.TERM_USE_LOCAL_TASKS; + process.env.TERM_USE_LOCAL_TASKS = undefined; }); afterEach(async () => { + if (savedEnv !== undefined) process.env.TERM_USE_LOCAL_TASKS = savedEnv; + else process.env.TERM_USE_LOCAL_TASKS = undefined; await rm(tempDir, { recursive: true, force: true }); }); diff --git a/src/lib/local-tasks.ts b/src/lib/local-tasks.ts index 988737975..4b33d96d4 100644 --- a/src/lib/local-tasks.ts +++ b/src/lib/local-tasks.ts @@ -1,7 +1,7 @@ /** * Local task backend (git-tracked) living in repo/.genie * - * This is meant for the macro repo (blanco) where bd is not required. + * Local task management for repos with .genie/ directory. */ import { existsSync } from 'node:fs'; @@ -101,7 +101,7 @@ export function isLocalTasksEnabled(repoPath: string): boolean { if (process.env.TERM_USE_LOCAL_TASKS === 'true') return true; if (process.env.TERM_USE_LOCAL_TASKS === 'false') return false; - // If repo has a .genie directory but bd isn't available, prefer local. + // If repo has a .genie directory, use local tasks. // For blanco we expect .genie to be tracked. return existsSync(getRepoGenieDir(repoPath)); } diff --git a/src/lib/orchestrator/event-monitor.ts b/src/lib/orchestrator/event-monitor.ts deleted file mode 100644 index 3535c69ab..000000000 --- a/src/lib/orchestrator/event-monitor.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Event monitor for Claude Code sessions - * - * Provides real-time monitoring of Claude Code sessions via polling, - * emitting events for state changes, output, and silence detection. - */ - -import { EventEmitter } from 'node:events'; -import * as tmux from '../tmux.js'; -import { type ClaudeState, detectCompletion, detectState } from './state-detector.js'; - -interface ClaudeEvent { - type: 'state_change' | 'output' | 'silence' | 'activity' | 'permission' | 'question' | 'error' | 'complete'; - state?: ClaudeState; - output?: string; - silenceMs?: number; - timestamp: number; -} - -interface EventMonitorOptions { - /** Polling interval in milliseconds (default: 500) */ - pollIntervalMs?: number; - /** Number of lines to capture (default: 30) */ - captureLines?: number; - /** Silence threshold for completion detection (default: 3000) */ - silenceThresholdMs?: number; - /** Specific pane ID to monitor (default: first pane of first window) */ - paneId?: string; -} - -export class EventMonitor extends EventEmitter { - private sessionName: string; - private paneId: string | null = null; - private explicitPaneId: string | null = null; - private options: Required<Omit<EventMonitorOptions, 'paneId'>>; - private pollTimer: ReturnType<typeof setInterval> | null = null; - private lastOutput = ''; - private lastOutputTime: number = Date.now(); - private lastState: ClaudeState | null = null; - private running = false; - - constructor(sessionName: string, options: EventMonitorOptions = {}) { - super(); - this.sessionName = sessionName; - this.explicitPaneId = options.paneId || null; - this.options = { - pollIntervalMs: options.pollIntervalMs ?? 500, - captureLines: options.captureLines ?? 30, - silenceThresholdMs: options.silenceThresholdMs ?? 3000, - }; - } - - /** - * Start monitoring the session - */ - async start(): Promise<void> { - if (this.running) return; - - // Use explicit pane ID if provided - if (this.explicitPaneId) { - this.paneId = this.explicitPaneId.startsWith('%') ? this.explicitPaneId : `%${this.explicitPaneId}`; - } else { - // Find session and get pane ID - const session = await tmux.findSessionByName(this.sessionName); - if (!session) { - throw new Error(`Session "${this.sessionName}" not found`); - } - - const windows = await tmux.listWindows(session.id); - if (!windows || windows.length === 0) { - throw new Error(`No windows found in session "${this.sessionName}"`); - } - - const activeWindow = windows.find((w) => w.active) || windows[0]; - - const panes = await tmux.listPanes(activeWindow.id); - if (!panes || panes.length === 0) { - throw new Error(`No panes found in session "${this.sessionName}"`); - } - - const activePane = panes.find((p) => p.active) || panes[0]; - this.paneId = activePane.id; - } - - this.running = true; - this.lastOutputTime = Date.now(); - - // Initial capture - await this.poll(); - - // Start polling - // Use unref() so the timer doesn't prevent process exit - this.pollTimer = setInterval(() => this.poll(), this.options.pollIntervalMs); - this.pollTimer.unref(); - - this.emit('started', { sessionName: this.sessionName, paneId: this.paneId }); - } - - /** - * Stop monitoring - */ - stop(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - this.running = false; - this.emit('stopped'); - } - - /** - * Check if monitor is running - */ - isRunning(): boolean { - return this.running; - } - - /** - * Get current state - */ - getCurrentState(): ClaudeState | null { - return this.lastState; - } - - /** - * Get time since last output change - */ - getSilenceMs(): number { - return Date.now() - this.lastOutputTime; - } - - /** - * Poll for changes - */ - private emitStateChangeEvents(newState: ClaudeState, now: number): void { - this.emitEvent({ type: 'state_change', state: newState, timestamp: now }); - - const eventType = ({ permission: 'permission', question: 'question', error: 'error' } as const)[ - newState.type as string - ]; - if (eventType) { - this.emitEvent({ type: eventType, state: newState, timestamp: now }); - } - } - - private handleNewOutput(output: string, now: number): void { - const newContent = this.getNewContent(this.lastOutput, output); - if (newContent) { - this.lastOutputTime = now; - this.emitEvent({ type: 'output', output: newContent, timestamp: now }); - this.emitEvent({ type: 'activity', timestamp: now }); - } - - const newState = detectState(output); - - if (this.lastState && newState.type !== this.lastState.type) { - this.emitStateChangeEvents(newState, now); - - const completion = detectCompletion(output, this.lastOutput); - if (completion.complete && completion.confidence > 0.6) { - this.emitEvent({ type: 'complete', state: newState, timestamp: now }); - } - } - - this.lastState = newState; - this.lastOutput = output; - } - - private checkSilence(now: number): void { - const silenceMs = now - this.lastOutputTime; - if ( - silenceMs >= this.options.silenceThresholdMs && - silenceMs % this.options.silenceThresholdMs < this.options.pollIntervalMs - ) { - this.emitEvent({ type: 'silence', silenceMs, timestamp: now }); - } - } - - private async poll(): Promise<void> { - if (!this.paneId || !this.running) return; - - try { - const output = await tmux.capturePaneContent(this.paneId, this.options.captureLines); - const now = Date.now(); - - if (output !== this.lastOutput) { - this.handleNewOutput(output, now); - } else { - this.checkSilence(now); - } - } catch (error) { - this.emit('poll_error', error); - } - } - - /** - * Get new content since last poll - */ - private getNewContent(oldOutput: string, newOutput: string): string | null { - if (oldOutput === newOutput) return null; - - // If old output is empty, return all new output - if (!oldOutput) return newOutput; - - // Find where old output ends in new output - const oldLines = oldOutput.split('\n'); - const newLines = newOutput.split('\n'); - - // Simple approach: find the last line of old output in new output - const lastOldLine = oldLines[oldLines.length - 1]; - const lastOldLineIndex = newLines.lastIndexOf(lastOldLine); - - if (lastOldLineIndex >= 0 && lastOldLineIndex < newLines.length - 1) { - return newLines.slice(lastOldLineIndex + 1).join('\n'); - } - - // If we can't find exact match, return the diff - // (this happens when lines scroll out of the capture buffer) - const oldSet = new Set(oldLines); - const newContent = newLines.filter((line) => !oldSet.has(line)); - - return newContent.length > 0 ? newContent.join('\n') : null; - } - - /** - * Emit a Claude event - */ - private emitEvent(event: ClaudeEvent): void { - this.emit(event.type, event); - this.emit('event', event); - } -} diff --git a/src/lib/orchestrator/index.ts b/src/lib/orchestrator/index.ts index d0e4b1604..83edec690 100644 --- a/src/lib/orchestrator/index.ts +++ b/src/lib/orchestrator/index.ts @@ -8,4 +8,3 @@ // Re-export all public APIs export * from './patterns.js'; export * from './state-detector.js'; -export * from './event-monitor.js'; diff --git a/src/lib/orchestrator/state-detector.ts b/src/lib/orchestrator/state-detector.ts index a946e5ccf..6fde4be2c 100644 --- a/src/lib/orchestrator/state-detector.ts +++ b/src/lib/orchestrator/state-detector.ts @@ -175,124 +175,3 @@ export function detectState(output: string, options: StateDetectorOptions = {}): return { ...baseState, type: 'unknown', confidence: minConfidence }; } - -/** - * Detect if output appears to be complete based on multiple signals - */ -export function detectCompletion( - output: string, - previousOutput: string, -): { complete: boolean; reason: string; confidence: number } { - const currentState = detectState(output); - const prevState = detectState(previousOutput); - - // Permission or question = definitely not complete (needs user input) - if (currentState.type === 'permission' || currentState.type === 'question') { - return { - complete: false, - reason: `awaiting ${currentState.type}`, - confidence: 0.95, - }; - } - - // Error state = complete (but with error) - if (currentState.type === 'error') { - return { - complete: true, - reason: 'error detected', - confidence: 0.8, - }; - } - - // Idle state = likely complete - if (currentState.type === 'idle') { - return { - complete: true, - reason: 'idle prompt detected', - confidence: currentState.confidence, - }; - } - - // Explicit completion markers - if (currentState.type === 'complete') { - return { - complete: true, - reason: 'completion marker detected', - confidence: currentState.confidence, - }; - } - - // Transition from working to not working - if (prevState.type === 'working' && currentState.type !== 'working') { - return { - complete: true, - reason: 'work finished', - confidence: 0.6, - }; - } - - // Still working - if (currentState.type === 'working' || currentState.type === 'tool_use') { - return { - complete: false, - reason: 'still working', - confidence: 0.7, - }; - } - - // Unknown - inconclusive - return { - complete: false, - reason: 'unknown state', - confidence: 0.3, - }; -} - -/** - * Extract permission details from output - */ -export function extractPermissionDetails(output: string): { - type: string; - command?: string; - file?: string; -} | null { - const cleanOutput = stripAnsi(output); - const match = getFirstMatch(cleanOutput, permissionPatterns); - - if (!match) return null; - - // Try to extract command or file from surrounding context - const lines = cleanOutput.split('\n'); - const matchLine = lines.findIndex( - (line) => - line.match(permissionPatterns[0].pattern) || - line.match(permissionPatterns[1].pattern) || - line.match(permissionPatterns[2].pattern), - ); - - let command: string | undefined; - let file: string | undefined; - - // Look at lines before the permission prompt for context - if (matchLine > 0) { - const contextLines = lines.slice(Math.max(0, matchLine - 5), matchLine).join('\n'); - - // Extract command - const cmdMatch = contextLines.match(/(?:Command|command|Bash|bash):\s*(.+)/); - if (cmdMatch) { - command = cmdMatch[1].trim(); - } - - // Extract file - const fileMatch = contextLines.match(/(?:File|file|Path|path):\s*(.+)/); - if (fileMatch) { - file = fileMatch[1].trim(); - } - } - - return { - type: match.type.replace('_permission', ''), - command, - file, - }; -} diff --git a/src/lib/protocol-router.test.ts b/src/lib/protocol-router.test.ts new file mode 100644 index 000000000..aa1959719 --- /dev/null +++ b/src/lib/protocol-router.test.ts @@ -0,0 +1,104 @@ +/** + * Protocol Router — Unit Tests + * + * Tests inbox retrieval and message routing logic. + * Full sendMessage tests require tmux (integration-level). + * + * Run with: bun test src/lib/protocol-router.test.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as mailbox from './mailbox.js'; + +// --------------------------------------------------------------------------- +// Environment isolation +// --------------------------------------------------------------------------- + +const ENV_KEYS = ['GENIE_HOME', 'TMUX', 'TMUX_PANE'] as const; +let savedEnv: Record<string, string | undefined>; +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'proto-router-test-')); + savedEnv = {}; + for (const k of ENV_KEYS) { + savedEnv[k] = process.env[k]; + } + process.env.GENIE_HOME = join(tempDir, '.genie-home'); + // Disable tmux to prevent auto-spawn attempts + process.env.TMUX = undefined as unknown as string; + process.env.TMUX_PANE = undefined as unknown as string; +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + await rm(tempDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// getInbox tests (uses mailbox directly, no tmux dependency) +// --------------------------------------------------------------------------- + +describe('getInbox', () => { + test('returns empty inbox for unknown worker', async () => { + const { getInbox } = await import('./protocol-router.js'); + const messages = await getInbox(tempDir, 'unknown-worker'); + expect(messages).toEqual([]); + }); + + test('returns messages after mailbox.send', async () => { + const { getInbox } = await import('./protocol-router.js'); + + // Directly write to mailbox (bypasses delivery which needs tmux) + await mailbox.send(tempDir, 'alice', 'bob', 'hello bob'); + await mailbox.send(tempDir, 'alice', 'bob', 'follow up'); + + const messages = await getInbox(tempDir, 'bob'); + expect(messages.length).toBe(2); + expect(messages[0].from).toBe('alice'); + expect(messages[0].body).toBe('hello bob'); + expect(messages[1].body).toBe('follow up'); + }); + + test('returns messages with correct metadata', async () => { + const { getInbox } = await import('./protocol-router.js'); + + await mailbox.send(tempDir, 'sender', 'receiver', 'test message'); + + const messages = await getInbox(tempDir, 'receiver'); + expect(messages.length).toBe(1); + + const msg = messages[0]; + expect(msg.id).toMatch(/^msg-/); + expect(msg.from).toBe('sender'); + expect(msg.to).toBe('receiver'); + expect(msg.body).toBe('test message'); + expect(msg.read).toBe(false); + expect(msg.deliveredAt).toBeNull(); + expect(msg.createdAt).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// sendMessage — no-tmux fallback behavior +// --------------------------------------------------------------------------- + +describe('sendMessage (no tmux)', () => { + test('returns not-found when worker does not exist and no tmux', async () => { + const { sendMessage } = await import('./protocol-router.js'); + + const result = await sendMessage(tempDir, 'alice', 'nonexistent', 'hello'); + expect(result.delivered).toBe(false); + expect(result.reason).toContain('not found'); + }); +}); diff --git a/src/lib/protocol-router.ts b/src/lib/protocol-router.ts index 13b221058..256291315 100644 --- a/src/lib/protocol-router.ts +++ b/src/lib/protocol-router.ts @@ -5,6 +5,12 @@ * messages between workers regardless of whether they are backed * by Claude or Codex. Delivery goes through the mailbox first * (DEC-7) and then pushes to the tmux pane when the worker is idle. + * + * Resolution order (directory-first): + * 1. Directory by name → built-in by name + * 2. Worker registry (ID > role > team:role) + * 3. Auto-spawn: if agent offline + in directory/registry → spawn → deliver + * 4. Native inbox fallback */ import * as registry from './agent-registry.js'; @@ -232,6 +238,14 @@ async function deliverViaNativeInbox( } } +/** + * Send a message to a recipient using directory-first resolution. + * + * Resolution order: + * 1. Live workers (ID > role > team:role) + * 2. Agent directory + worker registry → auto-spawn from template + * 3. Native team inbox fallback + */ export async function sendMessage( repoPath: string, from: string, @@ -255,8 +269,13 @@ export async function sendMessage( }; } - // 2. No live match — try auto-spawn from template (dead/missing worker) - // Try exact ID first, then role-based lookup (IDs are often prefixed, e.g. "genie-ideias" vs "ideias") + // 2. No live match — directory-first resolution for auto-spawn + // Check agent directory to validate recipient exists (enables spawn for + // agents registered in directory but not yet spawned in this session). + const { resolve } = await import('./agent-directory.js'); + const dirResolved = await resolve(to); + + // Also check worker registry for session context (provides claudeSessionId for resume) let worker = await registry.get(to); if (!worker) { const allWorkers = await registry.list(); @@ -264,9 +283,13 @@ export async function sendMessage( worker = allWorkers.find((w) => w.role === to && w.state === 'suspended') ?? allWorkers.find((w) => w.role === to) ?? null; } - const alive = await ensureWorkerAlive(worker, to); - if (alive) { - return deliverToWorker(repoPath, from, alive.worker, body); + + // Try auto-spawn if agent is known via directory OR registry + if (dirResolved || worker) { + const alive = await ensureWorkerAlive(worker, to); + if (alive) { + return deliverToWorker(repoPath, from, alive.worker, body); + } } // 3. Fallback: try native team inbox for agents not in worker registry diff --git a/src/lib/provider-adapters.test.ts b/src/lib/provider-adapters.test.ts index 7909722a7..3127a56d5 100644 --- a/src/lib/provider-adapters.test.ts +++ b/src/lib/provider-adapters.test.ts @@ -101,6 +101,58 @@ describe('buildClaudeCommand', () => { }); expect(result.command).toContain('--dangerously-skip-permissions'); }); + + it('includes --model flag when model is set', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'implementor', + model: 'opus', + }); + expect(result.command).toContain("--model 'opus'"); + }); + + it('includes --append-system-prompt-file by default when systemPromptFile is set', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + systemPromptFile: '/path/to/AGENTS.md', + }); + expect(result.command).toContain('--append-system-prompt-file'); + expect(result.command).toContain('/path/to/AGENTS.md'); + }); + + it('uses --system-prompt-file when promptMode is "system"', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + systemPromptFile: '/path/to/AGENTS.md', + promptMode: 'system', + }); + expect(result.command).toContain('--system-prompt-file'); + expect(result.command).not.toContain('--append-system-prompt-file'); + }); + + it('uses --append-system-prompt-file when promptMode is "append"', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + systemPromptFile: '/path/to/AGENTS.md', + promptMode: 'append', + }); + expect(result.command).toContain('--append-system-prompt-file'); + expect(result.command).not.toContain("--system-prompt-file '/path"); + }); + + it('does not include prompt file flags when systemPromptFile is not set', () => { + const result = buildClaudeCommand({ + provider: 'claude', + team: 'work', + role: 'implementor', + }); + expect(result.command).not.toContain('--system-prompt-file'); + expect(result.command).not.toContain('--append-system-prompt-file'); + }); }); // ============================================================================ diff --git a/src/lib/provider-adapters.ts b/src/lib/provider-adapters.ts index ed05a80f6..003474fb0 100644 --- a/src/lib/provider-adapters.ts +++ b/src/lib/provider-adapters.ts @@ -66,6 +66,14 @@ export interface SpawnParams { sessionId?: string; /** Session UUID to resume (emits --resume). Mutually exclusive with sessionId. */ resume?: string; + /** Path to a system prompt file (AGENTS.md). Emits --system-prompt-file or --append-system-prompt-file. */ + systemPromptFile?: string; + /** Inline system prompt text (for built-ins without an AGENTS.md file). Emits --append-system-prompt or --system-prompt. */ + systemPrompt?: string; + /** How to inject the system prompt file: 'system' replaces CC default, 'append' adds to it. */ + promptMode?: 'system' | 'append'; + /** Model override (e.g., 'sonnet', 'opus'). Emits --model flag. */ + model?: string; } /** Result of a successful launch-command build. */ @@ -106,6 +114,10 @@ const spawnParamsSchema = z.object({ .optional(), sessionId: z.string().uuid().optional(), resume: z.string().uuid().optional(), + systemPromptFile: z.string().optional(), + systemPrompt: z.string().optional(), + promptMode: z.enum(['system', 'append']).optional(), + model: z.string().optional(), }); /** @@ -201,6 +213,11 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { const parts: string[] = ['claude', '--dangerously-skip-permissions']; const env: Record<string, string> = {}; + // Always set GENIE_AGENT_NAME, even for non-native spawns + if (params.role) { + env.GENIE_AGENT_NAME = params.role; + } + if (params.nativeTeam?.enabled) { appendNativeTeamFlags(parts, env, params.nativeTeam, params); } @@ -213,6 +230,16 @@ export function buildClaudeCommand(params: SpawnParams): LaunchCommand { if (params.role) parts.push('--agent', escapeShellArg(params.role)); + if (params.model) parts.push('--model', escapeShellArg(params.model)); + + if (params.systemPromptFile) { + const flag = params.promptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file'; + parts.push(flag, escapeShellArg(params.systemPromptFile)); + } else if (params.systemPrompt) { + const flag = params.promptMode === 'system' ? '--system-prompt' : '--append-system-prompt'; + parts.push(flag, escapeShellArg(params.systemPrompt)); + } + if (params.extraArgs) { for (const arg of params.extraArgs) parts.push(escapeShellArg(arg)); } diff --git a/src/lib/session-store.ts b/src/lib/session-store.ts new file mode 100644 index 000000000..bc6580bd8 --- /dev/null +++ b/src/lib/session-store.ts @@ -0,0 +1,82 @@ +/** + * Session Store — Named leader session management. + * + * Maintains a name→UUID mapping at `~/.genie/sessions.json`. + * When `genie --session <name>` is invoked: + * - First time: creates a new UUID, returns isNew=true + * - Subsequent: returns the existing UUID, returns isNew=false + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +// ============================================================================ +// Types +// ============================================================================ + +interface SessionEntry { + uuid: string; + createdAt: string; + lastUsedAt: string; +} + +interface SessionMap { + sessions: Record<string, SessionEntry>; +} + +// ============================================================================ +// Configuration +// ============================================================================ + +function getSessionsPath(): string { + return join(process.env.GENIE_HOME ?? join(homedir(), '.genie'), 'sessions.json'); +} + +// ============================================================================ +// Internal +// ============================================================================ + +async function loadSessions(): Promise<SessionMap> { + try { + const content = await readFile(getSessionsPath(), 'utf-8'); + return JSON.parse(content); + } catch { + return { sessions: {} }; + } +} + +async function saveSessions(data: SessionMap): Promise<void> { + const filePath = getSessionsPath(); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(data, null, 2)); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Get or create a session UUID for the given name. + * Returns the UUID and whether it was newly created. + */ +export async function getOrCreateSession(name: string): Promise<{ uuid: string; isNew: boolean }> { + const data = await loadSessions(); + const existing = data.sessions[name]; + + if (existing) { + existing.lastUsedAt = new Date().toISOString(); + await saveSessions(data); + return { uuid: existing.uuid, isNew: false }; + } + + // crypto.randomUUID() is available as a global in Bun (Web Crypto API), no import needed + const uuid = crypto.randomUUID(); + data.sessions[name] = { + uuid, + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }; + await saveSessions(data); + return { uuid, isNew: true }; +} diff --git a/src/lib/skill-loader.ts b/src/lib/skill-loader.ts deleted file mode 100644 index 816287c27..000000000 --- a/src/lib/skill-loader.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Skill Loader - Find and load Claude skills - * - * Skills are stored in: - * 1. .claude/skills/<skill-name>/SKILL.md (project local) - * 2. ~/.claude/skills/<skill-name>/SKILL.md (user global) - * 3. ~/.claude/plugins/<plugin-name>/skills/<skill>/SKILL.md (plugins) - * - * Skill names are simple (wish, forge, review) and map to directories: - * - Direct match: wish -> wish/ - * - Prefixed: wish -> genie-wish/ - */ - -import { access, readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -export type SkillSource = 'local' | 'user' | 'plugin'; - -export interface SkillInfo { - name: string; - path: string; - skillFile: string; - description?: string; - source?: SkillSource; - pluginName?: string; -} - -/** - * Get possible directory names for a skill - * e.g., "wish" -> ["wish", "genie-wish"] - */ -function skillNameToDirs(skillName: string): string[] { - return [skillName, `genie-${skillName}`]; -} - -/** - * Check if a path exists - */ -async function pathExists(path: string): Promise<boolean> { - try { - await access(path); - return true; - } catch { - return false; - } -} - -/** - * Find a skill by name - * - * Search order: - * 1. .claude/skills/<skill-dir>/SKILL.md (project local) - * 2. ~/.claude/skills/<skill-dir>/SKILL.md (user global) - * - * For each location, tries both exact name and genie-prefixed name. - * - * @param skillName - Skill name (e.g., "wish", "forge", "review") - * @param projectRoot - Project root directory (defaults to cwd) - * @returns SkillInfo if found, null otherwise - */ -async function parseSkillDescription(filePath: string): Promise<string | undefined> { - try { - const content = await readFile(filePath, 'utf-8'); - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!frontmatterMatch) return undefined; - const descMatch = frontmatterMatch[1].match(/description:\s*["']?([^"'\n]+)["']?/); - return descMatch ? descMatch[1] : undefined; - } catch { - return undefined; - } -} - -export async function findSkill(skillName: string, projectRoot?: string): Promise<SkillInfo | null> { - const dirNames = skillNameToDirs(skillName); - const cwd = projectRoot || process.cwd(); - const searchLocations = [join(cwd, '.claude', 'skills'), join(homedir(), '.claude', 'skills')]; - - for (const location of searchLocations) { - for (const dirName of dirNames) { - const skillPath = join(location, dirName); - const skillFile = join(skillPath, 'SKILL.md'); - - if (await pathExists(skillFile)) { - return { - name: skillName, - path: skillPath, - skillFile, - description: await parseSkillDescription(skillFile), - }; - } - } - } - - return null; -} diff --git a/src/lib/spawn-command.test.ts b/src/lib/spawn-command.test.ts index 4e2ed5951..ae699351c 100644 --- a/src/lib/spawn-command.test.ts +++ b/src/lib/spawn-command.test.ts @@ -73,20 +73,6 @@ describe('buildSpawnCommand with claude launcher', () => { const command = buildSpawnCommand(profile, { resume: 'abc-123' }); expect(command).toBe("claude '--dangerously-skip-permissions' --resume 'abc-123'"); }); - - test('includes BEADS_DIR env prefix when provided', () => { - const profile = makeProfile({ - launcher: 'claude', - claudeArgs: ['--dangerously-skip-permissions'], - }); - const command = buildSpawnCommand(profile, { - sessionId: 'abc-123', - beadsDir: '/home/genie/workspace/project/.genie', - }); - expect(command).toBe( - "BEADS_DIR='/home/genie/workspace/project/.genie' claude '--dangerously-skip-permissions' --session-id 'abc-123'", - ); - }); }); // ============================================================================ @@ -109,30 +95,15 @@ describe('buildSpawnCommand edge cases', () => { launcher: 'claude', claudeArgs: [], }); - // Session IDs should be quoted to handle any special chars const command = buildSpawnCommand(profile, { sessionId: "abc'def" }); - // Should escape single quotes in the session ID expect(command).toBe("claude --session-id 'abc'\\''def'"); }); - test('handles beadsDir with spaces', () => { - const profile = makeProfile({ - launcher: 'claude', - claudeArgs: [], - }); - const command = buildSpawnCommand(profile, { - sessionId: 'test-123', - beadsDir: '/path/with spaces/.genie', - }); - expect(command).toBe("BEADS_DIR='/path/with spaces/.genie' claude --session-id 'test-123'"); - }); - test('sessionId takes precedence if both sessionId and resume provided', () => { const profile = makeProfile({ launcher: 'claude', claudeArgs: [], }); - // If both are somehow provided, sessionId should take precedence const command = buildSpawnCommand(profile, { sessionId: 'session-id-value', resume: 'resume-value', @@ -161,8 +132,6 @@ describe('shell injection prevention', () => { claudeArgs: ['--dangerously-skip-permissions', '--append-system-prompt', "'; rm -rf /; echo '"], }); const command = buildSpawnCommand(profile, { sessionId: 'test-123' }); - // The malicious payload should be safely escaped within single quotes - // Single quotes inside are escaped as '\'' (end quote, backslash-quote, start quote) expect(command).toBe( "claude '--dangerously-skip-permissions' '--append-system-prompt' ''\\''; rm -rf /; echo '\\''' --session-id 'test-123'", ); @@ -174,7 +143,6 @@ describe('shell injection prevention', () => { claudeArgs: ['--prompt', '`id`'], }); const command = buildSpawnCommand(profile, { sessionId: 'test-abc' }); - // Backticks should be safely enclosed in single quotes (no interpretation) expect(command).toBe("claude '--prompt' '`id`' --session-id 'test-abc'"); }); @@ -184,7 +152,6 @@ describe('shell injection prevention', () => { claudeArgs: ['--env', '$HOME'], }); const command = buildSpawnCommand(profile, { sessionId: 'test-def' }); - // Dollar signs should be safely enclosed in single quotes (no interpretation) expect(command).toBe("claude '--env' '$HOME' --session-id 'test-def'"); }); @@ -194,7 +161,6 @@ describe('shell injection prevention', () => { claudeArgs: ['--prompt', 'hello\nworld'], }); const command = buildSpawnCommand(profile, { sessionId: 'test-jkl' }); - // Newlines should be safely enclosed in single quotes expect(command).toBe("claude '--prompt' 'hello\nworld' --session-id 'test-jkl'"); }); }); diff --git a/src/lib/spawn-command.ts b/src/lib/spawn-command.ts index beccf3461..42bcc496b 100644 --- a/src/lib/spawn-command.ts +++ b/src/lib/spawn-command.ts @@ -27,8 +27,6 @@ interface SpawnOptions { sessionId?: string; /** Session ID to resume (--resume flag) */ resume?: string; - /** Path to beads directory for BEADS_DIR env var */ - beadsDir?: string; } // ============================================================================ @@ -51,7 +49,7 @@ function escapeForShell(str: string): string { * Build a spawn command string based on profile and options * * @param profile - WorkerProfile defining launcher and args - * @param options - SpawnOptions with sessionId, resume, and beadsDir + * @param options - SpawnOptions with sessionId and resume * @returns Command string ready to be passed to tmux.executeCommand() * @throws Error if no profile is provided * @@ -69,12 +67,7 @@ export function buildSpawnCommand(profile: WorkerProfile | undefined, options: S const parts: string[] = []; - // 1. Add BEADS_DIR env prefix if provided - if (options.beadsDir) { - parts.push(`BEADS_DIR='${escapeForShell(options.beadsDir)}'`); - } - - // 2. Build command + // Build command parts.push('claude'); // Add claude args (escaped for shell safety) @@ -82,7 +75,7 @@ export function buildSpawnCommand(profile: WorkerProfile | undefined, options: S parts.push(`'${escapeForShell(arg)}'`); } - // 3. Add session-id or resume flag + // Add session-id or resume flag // sessionId takes precedence over resume if (options.sessionId) { parts.push('--session-id'); diff --git a/src/lib/target-resolver.test.ts b/src/lib/target-resolver.test.ts index d0a4ee1fa..e42af70cf 100644 --- a/src/lib/target-resolver.test.ts +++ b/src/lib/target-resolver.test.ts @@ -313,7 +313,7 @@ describe('Error paths', () => { ).rejects.toThrow(/not found/i); }); - test('error message includes suggestion to run genie agent list', async () => { + test('error message includes suggestion to run genie ls', async () => { try { await resolveTarget('ghost-worker', { checkLiveness: false, @@ -323,7 +323,7 @@ describe('Error paths', () => { // Should not reach here expect(true).toBe(false); } catch (error: any) { - expect(error.message).toMatch(/genie agent list|genie agent/i); + expect(error.message).toMatch(/genie ls|genie kill/i); } }); }); diff --git a/src/lib/target-resolver.ts b/src/lib/target-resolver.ts index 234d8ef17..cf0bc0446 100644 --- a/src/lib/target-resolver.ts +++ b/src/lib/target-resolver.ts @@ -140,7 +140,7 @@ async function defaultDeriveSession(paneId: string): Promise<string | null> { /** * Resolve a target string to a tmux pane ID using a 3-level resolution chain. * - * @param target - The target string (e.g., "%17", "bd-42", "bd-42:1", "genie:OMNI") + * @param target - The target string (e.g., "%17", "wish-42", "wish-42:1", "genie:OMNI") * @param options - Optional overrides for testing * @returns ResolvedTarget with paneId and metadata * @throws Error with prescriptive message if target cannot be resolved @@ -184,13 +184,13 @@ async function resolveWindowId( ): Promise<ResolvedTarget> { const matchingWorker = Object.values(workers).find((w) => w.windowId === target); if (!matchingWorker) { - throw new Error(`Window "${target}" not found in worker registry.\nRun 'genie agent list' to list workers.`); + throw new Error(`Window "${target}" not found in worker registry.\nRun 'genie ls' to list agents.`); } if (opts.checkLiveness) { await assertLive( matchingWorker.paneId, opts.isPaneLive, - `Window ${target}: worker ${matchingWorker.id} pane ${matchingWorker.paneId} is dead. Run 'genie agent kill ${matchingWorker.id}' to clean up.`, + `Window ${target}: worker ${matchingWorker.id} pane ${matchingWorker.paneId} is dead. Run 'genie kill ${matchingWorker.id}' to clean up.`, ); } return { @@ -257,7 +257,7 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} await assertLive( paneId, isPaneLive, - `Worker ${leftSide}: pane ${paneId} is dead. Run 'genie agent kill ${leftSide}' to clean up.`, + `Worker ${leftSide}: pane ${paneId} is dead. Run 'genie kill ${leftSide}' to clean up.`, () => cleanupDeadPane(leftSide, paneId), ); } @@ -268,7 +268,7 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} const sessionWindowResult = await tmuxLookup(leftSide, rightSide); if (!sessionWindowResult) { throw new Error( - `Target "${target}" not found. No worker "${leftSide}" in registry and no tmux session:window "${leftSide}:${rightSide}" found.\nRun 'genie agent list' to list workers.`, + `Target "${target}" not found. No worker "${leftSide}" in registry and no tmux session:window "${leftSide}:${rightSide}" found.\nRun 'genie ls' to list agents.`, ); } if (checkLiveness) { @@ -288,14 +288,14 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} await assertLive( worker.paneId, isPaneLive, - `Worker ${target}: pane ${worker.paneId} is dead. Run 'genie agent kill ${target}' to clean up.`, + `Worker ${target}: pane ${worker.paneId} is dead. Run 'genie kill ${target}' to clean up.`, () => cleanupDeadPane(target, worker.paneId), ); } return { paneId: worker.paneId, session: worker.session, workerId: target, resolvedVia: 'worker' }; } - throw new Error(`Target "${target}" not found. Not a worker or pane ID.\nRun 'genie agent list' to list workers.`); + throw new Error(`Target "${target}" not found. Not a worker or pane ID.\nRun 'genie ls' to list agents.`); } // ============================================================================ @@ -306,8 +306,8 @@ export async function resolveTarget(target: string, options: ResolveOptions = {} * Format a human-readable label from a resolved target. * * Examples: - * worker "bd-42" pane %17, session "genie" -> "bd-42 (pane %17, session genie)" - * worker "bd-42:1" pane %22, session "genie" -> "bd-42:1 (pane %22, session genie)" + * worker "wish-42" pane %17, session "genie" -> "wish-42 (pane %17, session genie)" + * worker "wish-42:1" pane %22, session "genie" -> "wish-42:1 (pane %22, session genie)" * raw pane "%17" -> "%17 (pane %17)" */ export function formatResolvedLabel(resolved: ResolvedTarget, originalTarget: string): string { diff --git a/src/lib/task-backend.ts b/src/lib/task-backend.ts deleted file mode 100644 index 29ffde686..000000000 --- a/src/lib/task-backend.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Task backend abstraction. - * - * - Beads backend (bd) for repo-level issues - * - Local backend (.genie tracked) for macro repo (blanco) - */ - -import { $ } from 'bun'; -import * as local from './local-tasks.js'; - -export interface TaskSummary { - id: string; - title: string; - status: string; - description?: string; - blockedBy?: string[]; -} - -export interface QueueStatus { - ready: string[]; - blocked: string[]; -} - -export interface UpdateTaskOptions { - status?: string; - title?: string; - blockedBy?: string[]; - addBlockedBy?: string[]; -} - -export interface TaskBackend { - kind: 'beads' | 'local'; - create(title: string, options?: { description?: string; parent?: string }): Promise<TaskSummary>; - get(id: string): Promise<TaskSummary | null>; - claim(id: string): Promise<boolean>; // in_progress - markDone(id: string): Promise<boolean>; - update(id: string, options: UpdateTaskOptions): Promise<TaskSummary | null>; - queue(): Promise<QueueStatus>; -} - -function hasBd(): boolean { - const BunExt = Bun as unknown as { which?: (name: string) => string | null }; - return typeof BunExt.which === 'function' ? Boolean(BunExt.which('bd')) : true; -} - -async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> { - try { - const result = await $`bd ${args}`.quiet(); - return { stdout: result.stdout.toString().trim(), exitCode: 0 }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const shellErr = error as { stdout?: Buffer; exitCode?: number }; - return { - stdout: shellErr.stdout?.toString().trim() || message, - exitCode: shellErr.exitCode || 1, - }; - } -} - -type BdRunner = (args: string[]) => Promise<{ stdout: string; exitCode: number }>; - -async function parseReadyIssues(run: BdRunner): Promise<string[]> { - try { - const { stdout, exitCode } = await run(['ready', '--json']); - if (exitCode !== 0 || !stdout) return []; - try { - return JSON.parse(stdout).map((issue: { id: string }) => `${issue.id}`); - } catch { - return stdout - .split('\n') - .filter((l) => l.trim()) - .map((l) => l.match(/^(bd-\d+)/)?.[1]) - .filter(Boolean) as string[]; - } - } catch { - return []; - } -} - -async function parseBlockedIssues(run: BdRunner): Promise<string[]> { - try { - const { stdout, exitCode } = await run(['list', '--json']); - if (exitCode !== 0 || !stdout) return []; - return JSON.parse(stdout) - .filter((issue: { blockedBy?: string[] }) => issue.blockedBy?.length) - .map((issue: { id: string; blockedBy: string[] }) => `${issue.id} (blocked by ${issue.blockedBy.join(', ')})`); - } catch { - return []; - } -} - -export function getBackend(repoPath: string): TaskBackend { - const useLocal = local.isLocalTasksEnabled(repoPath) || !hasBd(); - - if (useLocal) { - return { - kind: 'local', - async create(title, options) { - const task = await local.createWishTask(repoPath, title, { - description: options?.description, - parent: options?.parent, - }); - return { - id: task.id, - title: task.title, - status: task.status, - description: task.description, - blockedBy: task.blockedBy, - }; - }, - async get(id) { - const task = await local.getTask(repoPath, id); - return task - ? { - id: task.id, - title: task.title, - status: task.status, - description: task.description, - blockedBy: task.blockedBy, - } - : null; - }, - async claim(id) { - return local.claimTask(repoPath, id); - }, - async markDone(id) { - return local.markDone(repoPath, id); - }, - async update(id, options) { - const task = await local.updateTask(repoPath, id, { - status: options.status as local.LocalTaskStatus | undefined, - title: options.title, - blockedBy: options.blockedBy, - addBlockedBy: options.addBlockedBy, - }); - return task - ? { - id: task.id, - title: task.title, - status: task.status, - description: task.description, - blockedBy: task.blockedBy, - } - : null; - }, - async queue() { - return local.getQueue(repoPath); - }, - }; - } - - // beads backend - return { - kind: 'beads', - async create(title, options) { - const args = ['create', title]; - if (options?.description) args.push('--description', options.description); - const { stdout, exitCode } = await runBd(args); - if (exitCode !== 0) throw new Error(stdout); - const idMatch = stdout.match(/bd-\d+/); - const issueId = idMatch ? idMatch[0] : null; - if (!issueId) throw new Error(stdout || 'Failed to parse created id'); - if (options?.parent) { - await runBd(['update', issueId, '--blocked-by', options.parent]); - } - const issue = await this.get(issueId); - return issue || { id: issueId, title, status: 'ready' }; - }, - async get(id) { - const { stdout, exitCode } = await runBd(['show', id, '--json']); - if (exitCode !== 0 || !stdout) return null; - try { - const parsed = JSON.parse(stdout); - const issue = Array.isArray(parsed) ? parsed[0] : parsed; - if (!issue) return null; - return { - id: issue.id, - title: issue.title || issue.description?.substring(0, 50) || 'Untitled', - status: issue.status, - description: issue.description, - blockedBy: issue.blockedBy || [], - }; - } catch { - return null; - } - }, - async claim(id) { - const { exitCode } = await runBd(['update', id, '--status', 'in_progress']); - return exitCode === 0; - }, - async markDone(id) { - // close happens elsewhere; we can mark done if beads supports update status - const { exitCode } = await runBd(['update', id, '--status', 'done']); - return exitCode === 0; - }, - async update(id, options) { - const args = ['update', id]; - if (options.status) args.push('--status', options.status); - if (options.title) args.push('--title', options.title); - if (options.blockedBy?.length) args.push('--blocked-by', options.blockedBy.join(',')); - if (options.addBlockedBy?.length) { - const current = await this.get(id); - if (current) { - const merged = new Set([...(current.blockedBy || []), ...options.addBlockedBy]); - args.push('--blocked-by', Array.from(merged).join(',')); - } - } - const { exitCode } = await runBd(args); - return exitCode === 0 ? this.get(id) : null; - }, - async queue() { - return { ready: await parseReadyIssues(runBd), blocked: await parseBlockedIssues(runBd) }; - }, - }; -} diff --git a/src/lib/team-chat.test.ts b/src/lib/team-chat.test.ts new file mode 100644 index 000000000..872a6066d --- /dev/null +++ b/src/lib/team-chat.test.ts @@ -0,0 +1,128 @@ +/** + * Team Chat — Unit Tests + * + * Tests JSONL-based group chat channel for teams. + * + * Run with: bun test src/lib/team-chat.test.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { readFile, rm } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { postMessage, readMessages } from './team-chat.js'; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'team-chat-test-')); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe('postMessage', () => { + test('creates JSONL file and appends message', async () => { + const msg = await postMessage(tempDir, 'feat/auth', 'implementor', 'hello team'); + + expect(msg.id).toMatch(/^chat-/); + expect(msg.sender).toBe('implementor'); + expect(msg.body).toBe('hello team'); + expect(msg.timestamp).toBeTruthy(); + + // Verify file contents + const filePath = join(tempDir, '.genie', 'chat', 'feat--auth.jsonl'); + const content = await readFile(filePath, 'utf-8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBe(1); + + const parsed = JSON.parse(lines[0]); + expect(parsed.sender).toBe('implementor'); + expect(parsed.body).toBe('hello team'); + }); + + test('appends multiple messages to same channel', async () => { + await postMessage(tempDir, 'dev-team', 'alice', 'first message'); + await postMessage(tempDir, 'dev-team', 'bob', 'second message'); + await postMessage(tempDir, 'dev-team', 'alice', 'third message'); + + const filePath = join(tempDir, '.genie', 'chat', 'dev-team.jsonl'); + const content = await readFile(filePath, 'utf-8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBe(3); + + const messages = lines.map((l) => JSON.parse(l)); + expect(messages[0].sender).toBe('alice'); + expect(messages[1].sender).toBe('bob'); + expect(messages[2].sender).toBe('alice'); + }); + + test('generates unique IDs for each message', async () => { + const msg1 = await postMessage(tempDir, 'team', 'agent', 'msg1'); + const msg2 = await postMessage(tempDir, 'team', 'agent', 'msg2'); + + expect(msg1.id).not.toBe(msg2.id); + }); + + test('sanitizes team name with slashes for filename', async () => { + await postMessage(tempDir, 'feat/my-feature', 'agent', 'hello'); + + const filePath = join(tempDir, '.genie', 'chat', 'feat--my-feature.jsonl'); + const content = await readFile(filePath, 'utf-8'); + expect(content.trim()).toBeTruthy(); + }); +}); + +describe('readMessages', () => { + test('returns empty array for non-existent channel', async () => { + const messages = await readMessages(tempDir, 'no-such-team'); + expect(messages).toEqual([]); + }); + + test('returns all posted messages', async () => { + await postMessage(tempDir, 'team', 'alice', 'hello'); + await postMessage(tempDir, 'team', 'bob', 'world'); + + const messages = await readMessages(tempDir, 'team'); + expect(messages.length).toBe(2); + expect(messages[0].sender).toBe('alice'); + expect(messages[0].body).toBe('hello'); + expect(messages[1].sender).toBe('bob'); + expect(messages[1].body).toBe('world'); + }); + + test('filters by since timestamp', async () => { + await postMessage(tempDir, 'team', 'alice', 'old message'); + + // Small delay to ensure timestamp difference + await new Promise((r) => setTimeout(r, 10)); + const midpoint = new Date().toISOString(); + await new Promise((r) => setTimeout(r, 10)); + + await postMessage(tempDir, 'team', 'bob', 'new message'); + + const filtered = await readMessages(tempDir, 'team', midpoint); + expect(filtered.length).toBe(1); + expect(filtered[0].sender).toBe('bob'); + expect(filtered[0].body).toBe('new message'); + }); + + test('returns all messages when since is before all messages', async () => { + const longAgo = '2020-01-01T00:00:00.000Z'; + await postMessage(tempDir, 'team', 'alice', 'msg1'); + await postMessage(tempDir, 'team', 'bob', 'msg2'); + + const messages = await readMessages(tempDir, 'team', longAgo); + expect(messages.length).toBe(2); + }); + + test('returns empty when since is in the future', async () => { + await postMessage(tempDir, 'team', 'alice', 'msg1'); + + const future = '2099-01-01T00:00:00.000Z'; + const messages = await readMessages(tempDir, 'team', future); + expect(messages.length).toBe(0); + }); +}); diff --git a/src/lib/team-chat.ts b/src/lib/team-chat.ts new file mode 100644 index 000000000..d82055457 --- /dev/null +++ b/src/lib/team-chat.ts @@ -0,0 +1,101 @@ +/** + * Team Chat — JSONL-based group channel per team. + * + * Each team has a chat channel stored at `<repoPath>/.genie/chat/<team-name>.jsonl`. + * Messages are appended as newline-delimited JSON for efficient append-only writes. + */ + +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { v4 as uuidv4 } from 'uuid'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ChatMessage { + /** Unique message ID. */ + id: string; + /** Sender agent name. */ + sender: string; + /** Message body text. */ + body: string; + /** ISO timestamp when message was posted. */ + timestamp: string; +} + +// ============================================================================ +// Paths +// ============================================================================ + +function chatDir(repoPath: string): string { + return join(repoPath, '.genie', 'chat'); +} + +function chatFilePath(repoPath: string, teamName: string): string { + const safeName = teamName.replace(/\//g, '--'); + return join(chatDir(repoPath), `${safeName}.jsonl`); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Post a message to a team's chat channel. + * Appends a JSON line to the team's JSONL file. + */ +export async function postMessage( + repoPath: string, + teamName: string, + sender: string, + body: string, +): Promise<ChatMessage> { + const msg: ChatMessage = { + id: `chat-${uuidv4()}`, + sender, + body, + timestamp: new Date().toISOString(), + }; + + const dir = chatDir(repoPath); + await mkdir(dir, { recursive: true }); + + const filePath = chatFilePath(repoPath, teamName); + await appendFile(filePath, `${JSON.stringify(msg)}\n`); + + return msg; +} + +/** + * Read messages from a team's chat channel. + * Optionally filter messages since a given timestamp. + */ +export async function readMessages(repoPath: string, teamName: string, since?: string): Promise<ChatMessage[]> { + const filePath = chatFilePath(repoPath, teamName); + + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + return []; + } + + const lines = content.trim().split('\n').filter(Boolean); + let messages: ChatMessage[] = []; + + for (const line of lines) { + try { + messages.push(JSON.parse(line)); + } catch { + // Skip malformed lines + } + } + + if (since) { + const sinceTime = new Date(since).getTime(); + messages = messages.filter((m) => new Date(m.timestamp).getTime() >= sinceTime); + } + + return messages; +} diff --git a/src/lib/team-lead-command.ts b/src/lib/team-lead-command.ts index 5ac4c7139..60b56e37d 100644 --- a/src/lib/team-lead-command.ts +++ b/src/lib/team-lead-command.ts @@ -5,8 +5,8 @@ * command for launching a team-lead. This module prevents drift between the * two implementations (which previously caused GENIE_AGENT_NAME regressions). * - * System prompt is written to ~/.genie/prompts/<team>.md and loaded via - * $(cat path) to avoid "argument list too long" errors in tmux send-keys. + * System prompt is written to ~/.genie/prompts/<team>.md and referenced via + * --append-system-prompt-file (or --system-prompt-file) to keep the command short. */ import { mkdirSync, writeFileSync } from 'node:fs'; @@ -22,26 +22,11 @@ export function shellQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; } -/** - * Write the system prompt (AGENTS.md content) to ~/.genie/prompts/<team>.md. - * Returns the file path, or null if there's no prompt to write. - * - * Note: The team-lead orchestration prompt is now injected into - * ~/.claude/rules/genie-orchestration.md by install.sh at install time, - * so Claude Code auto-loads it every session without runtime path resolution. - */ -function persistSystemPrompt(teamName: string, systemPrompt?: string): string | null { - if (!systemPrompt) return null; - - mkdirSync(PROMPTS_DIR, { recursive: true }); - const promptPath = join(PROMPTS_DIR, `${sanitizeTeamName(teamName)}.md`); - writeFileSync(promptPath, systemPrompt, 'utf-8'); - return promptPath; -} - interface BuildTeamLeadCommandOptions { systemPrompt?: string; resumeSessionId?: string; + /** Set session ID for a new session (mutually exclusive with resumeSessionId) */ + sessionId?: string; /** Override promptMode instead of reading from config (useful for testing) */ promptMode?: 'append' | 'system'; } @@ -53,7 +38,8 @@ interface BuildTeamLeadCommandOptions { * CC requires --agent-id, --agent-name, and --team-name together. * The team lead uses agent-id "team-lead@<team>" by convention. * - * System prompt is loaded from file via $(cat) to keep the command short. + * System prompt is written to file and loaded via --append-system-prompt-file + * (or --system-prompt-file) to keep the command short. */ export function buildTeamLeadCommand(teamName: string, options?: BuildTeamLeadCommandOptions): string { const sanitized = sanitizeTeamName(teamName); @@ -72,14 +58,19 @@ export function buildTeamLeadCommand(teamName: string, options?: BuildTeamLeadCo if (options?.resumeSessionId) { parts.push(`--resume ${shellQuote(options.resumeSessionId)}`); + } else if (options?.sessionId) { + parts.push(`--session-id ${shellQuote(options.sessionId)}`); } - // Write prompt to file, reference via $(cat) to avoid arg-list-too-long - const promptPath = persistSystemPrompt(sanitized, options?.systemPrompt); - if (promptPath) { + // Write prompt to file, reference via --*-system-prompt-file + if (options?.systemPrompt) { + mkdirSync(PROMPTS_DIR, { recursive: true }); + const promptPath = join(PROMPTS_DIR, `${sanitized}.md`); + writeFileSync(promptPath, options.systemPrompt, 'utf-8'); + const resolvedPromptMode = options?.promptMode ?? loadGenieConfigSync().promptMode; - const promptFlag = resolvedPromptMode === 'system' ? '--system-prompt' : '--append-system-prompt'; - parts.push(`${promptFlag} "$(cat ${shellQuote(promptPath)})"`); + const promptFlag = resolvedPromptMode === 'system' ? '--system-prompt-file' : '--append-system-prompt-file'; + parts.push(`${promptFlag} ${shellQuote(promptPath)}`); } return parts.join(' '); diff --git a/src/lib/team-manager.test.ts b/src/lib/team-manager.test.ts new file mode 100644 index 000000000..e395eb3bc --- /dev/null +++ b/src/lib/team-manager.test.ts @@ -0,0 +1,232 @@ +/** + * Tests for Team Manager — CRUD for team lifecycle with git worktree integration. + * Run with: bun test src/lib/team-manager.test.ts + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { $ } from 'bun'; +import { createTeam, disbandTeam, fireAgent, getTeam, hireAgent, listMembers, listTeams } from './team-manager.js'; + +// ============================================================================ +// Test Setup +// ============================================================================ + +const TEST_DIR = '/tmp/team-manager-test'; +const TEST_REPO = join(TEST_DIR, 'test-repo'); + +async function setupTestRepo(): Promise<void> { + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore + } + + await mkdir(TEST_REPO, { recursive: true }); + await $`git -C ${TEST_REPO} init`.quiet(); + await $`git -C ${TEST_REPO} config user.email "test@test.com"`.quiet(); + await $`git -C ${TEST_REPO} config user.name "Test"`.quiet(); + + // Create initial commit on a "dev" branch + await writeFile(join(TEST_REPO, 'README.md'), '# Test Repo'); + await $`git -C ${TEST_REPO} add .`.quiet(); + await $`git -C ${TEST_REPO} commit -m "Initial commit"`.quiet(); + + // Create dev branch (current branch acts as dev) + await $`git -C ${TEST_REPO} branch dev`.quiet(); +} + +async function cleanupTestRepo(): Promise<void> { + try { + // Remove all worktrees first + const result = await $`git -C ${TEST_REPO} worktree list --porcelain`.quiet(); + const paths = result.stdout + .toString() + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice(9)) + .filter((path) => path !== TEST_REPO); + + for (const p of paths) { + try { + await $`git -C ${TEST_REPO} worktree remove ${p} --force`.quiet(); + } catch { + // Ignore + } + } + } catch { + // Ignore + } + + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('Team Manager', () => { + beforeAll(async () => { + await setupTestRepo(); + }); + + afterAll(async () => { + await cleanupTestRepo(); + }); + + describe('createTeam', () => { + test('creates team with worktree', async () => { + const config = await createTeam('feat/test-create', TEST_REPO, 'dev'); + + expect(config.name).toBe('feat/test-create'); + expect(config.repo).toBe(TEST_REPO); + expect(config.baseBranch).toBe('dev'); + expect(config.members).toEqual([]); + expect(config.worktreePath).toContain('feat/test-create'); + expect(existsSync(config.worktreePath)).toBe(true); + }); + + test('is idempotent — re-running returns existing config', async () => { + const first = await createTeam('feat/idempotent', TEST_REPO, 'dev'); + const second = await createTeam('feat/idempotent', TEST_REPO, 'dev'); + + expect(second.name).toBe(first.name); + expect(second.createdAt).toBe(first.createdAt); + }); + + test('creates branch from base branch', async () => { + const config = await createTeam('feat/branched', TEST_REPO, 'dev'); + + // Verify branch exists in worktree + const result = await $`git -C ${config.worktreePath} branch --show-current`.quiet(); + expect(result.stdout.toString().trim()).toBe('feat/branched'); + }); + }); + + describe('getTeam', () => { + test('returns team config for existing team', async () => { + await createTeam('feat/get-test', TEST_REPO, 'dev'); + const config = await getTeam(TEST_REPO, 'feat/get-test'); + + expect(config).not.toBeNull(); + expect(config!.name).toBe('feat/get-test'); + }); + + test('returns null for non-existent team', async () => { + const config = await getTeam(TEST_REPO, 'nonexistent'); + expect(config).toBeNull(); + }); + }); + + describe('listTeams', () => { + test('lists all teams', async () => { + const teams = await listTeams(TEST_REPO); + expect(teams.length).toBeGreaterThan(0); + + const names = teams.map((t) => t.name); + expect(names).toContain('feat/test-create'); + }); + }); + + describe('hireAgent', () => { + test('adds agent to team members', async () => { + await createTeam('feat/hire-test', TEST_REPO, 'dev'); + const added = await hireAgent('feat/hire-test', 'implementor', TEST_REPO); + + expect(added).toEqual(['implementor']); + + const config = await getTeam(TEST_REPO, 'feat/hire-test'); + expect(config!.members).toContain('implementor'); + }); + + test('returns empty array for duplicate hire', async () => { + await createTeam('feat/hire-dup', TEST_REPO, 'dev'); + await hireAgent('feat/hire-dup', 'tester', TEST_REPO); + const added = await hireAgent('feat/hire-dup', 'tester', TEST_REPO); + + expect(added).toEqual([]); + }); + + test('hire council adds all 10 council members', async () => { + await createTeam('feat/hire-council', TEST_REPO, 'dev'); + const added = await hireAgent('feat/hire-council', 'council', TEST_REPO); + + expect(added.length).toBe(10); + expect(added).toContain('council-questioner'); + expect(added).toContain('council-architect'); + + const config = await getTeam(TEST_REPO, 'feat/hire-council'); + expect(config!.members.length).toBe(10); + }); + + test('throws for non-existent team', async () => { + expect(hireAgent('nonexistent', 'agent', TEST_REPO)).rejects.toThrow('not found'); + }); + }); + + describe('fireAgent', () => { + test('removes agent from team members', async () => { + await createTeam('feat/fire-test', TEST_REPO, 'dev'); + await hireAgent('feat/fire-test', 'reviewer', TEST_REPO); + + const removed = await fireAgent('feat/fire-test', 'reviewer', TEST_REPO); + expect(removed).toBe(true); + + const config = await getTeam(TEST_REPO, 'feat/fire-test'); + expect(config!.members).not.toContain('reviewer'); + }); + + test('returns false for agent not in team', async () => { + await createTeam('feat/fire-miss', TEST_REPO, 'dev'); + const removed = await fireAgent('feat/fire-miss', 'nobody', TEST_REPO); + expect(removed).toBe(false); + }); + + test('throws for non-existent team', async () => { + expect(fireAgent('nonexistent', 'agent', TEST_REPO)).rejects.toThrow('not found'); + }); + }); + + describe('listMembers', () => { + test('returns members of existing team', async () => { + await createTeam('feat/list-members', TEST_REPO, 'dev'); + await hireAgent('feat/list-members', 'debugger', TEST_REPO); + + const members = await listMembers(TEST_REPO, 'feat/list-members'); + expect(members).toEqual(['debugger']); + }); + + test('returns null for non-existent team', async () => { + const members = await listMembers(TEST_REPO, 'nonexistent'); + expect(members).toBeNull(); + }); + }); + + describe('disbandTeam', () => { + test('removes worktree and config', async () => { + const config = await createTeam('feat/disband-test', TEST_REPO, 'dev'); + const worktreePath = config.worktreePath; + + const disbanded = await disbandTeam(TEST_REPO, 'feat/disband-test'); + expect(disbanded).toBe(true); + + // Worktree should be gone + expect(existsSync(worktreePath)).toBe(false); + + // Team config should be gone + const team = await getTeam(TEST_REPO, 'feat/disband-test'); + expect(team).toBeNull(); + }); + + test('returns false for non-existent team', async () => { + const disbanded = await disbandTeam(TEST_REPO, 'nonexistent'); + expect(disbanded).toBe(false); + }); + }); +}); diff --git a/src/lib/team-manager.ts b/src/lib/team-manager.ts index f3d89373d..d3903a272 100644 --- a/src/lib/team-manager.ts +++ b/src/lib/team-manager.ts @@ -1,100 +1,46 @@ /** - * Team Manager — CRUD for team lifecycle and blueprint defaults. + * Team Manager — CRUD for team lifecycle with git worktree integration. * - * Teams are stored as JSON files in `.genie/teams/<name>.json`. - * Blueprints provide role descriptors that inform spawn defaults - * without forcing a team-level provider lock (DEC-2). + * Teams are stored as JSON files in `.genie/teams/<safe-name>.json`. + * Each team owns a git worktree at `<worktreeBase>/<team-name>`. + * Team name IS the branch name (conventional prefixes: feat/, fix/, chore/, etc.). */ import { existsSync } from 'node:fs'; -import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rm, unlink, writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; +import { $ } from 'bun'; +import * as registry from './agent-registry.js'; +import { BUILTIN_COUNCIL_MEMBERS } from './builtin-agents.js'; import * as nativeTeamsManager from './claude-native-teams.js'; -import type { ProviderName } from './provider-adapters.js'; +import { loadGenieConfigSync } from './genie-config.js'; // ============================================================================ // Types // ============================================================================ -/** Role descriptor within a team blueprint. */ -interface RoleDescriptor { - /** Human-readable role name (e.g., "implementor", "tester"). */ - name: string; - /** Suggested provider for this role (optional — not enforced). */ - suggestedProvider?: ProviderName; - /** Suggested skill for codex workers in this role. */ - suggestedSkill?: string; - /** Free-form description of what this role does. */ - description?: string; -} - -/** Blueprint schema — provides default role descriptors for a team type. */ -interface Blueprint { - name: string; - roles: RoleDescriptor[]; -} - /** Persisted team configuration. */ -interface TeamConfig { - /** Team name (unique identifier). */ +export interface TeamConfig { + /** Team name — also the git branch name (e.g., "feat/auth-bug"). */ name: string; - /** Blueprint that was used to create the team (informational). */ - blueprint?: string; - /** Role descriptors (may be customized from blueprint defaults). */ - roles: RoleDescriptor[]; + /** Absolute path to the repository this team works in. */ + repo: string; + /** Branch this team was created from (e.g., "dev"). */ + baseBranch: string; + /** Absolute path to the git worktree directory. */ + worktreePath: string; + /** Agent name of the team leader (if assigned). */ + leader?: string; + /** Array of agent names that are members of this team. */ + members: string[]; /** ISO timestamp of creation. */ createdAt: string; - /** ISO timestamp of last update. */ - updatedAt: string; /** Parent session UUID for Claude Code native team IPC. */ nativeTeamParentSessionId?: string; /** Whether this team uses Claude Code native teams. */ nativeTeamsEnabled?: boolean; } -// ============================================================================ -// Built-in Blueprints -// ============================================================================ - -const BLUEPRINTS: Record<string, Blueprint> = { - work: { - name: 'work', - roles: [ - { name: 'implementor', description: 'Implements features and fixes bugs' }, - { name: 'tester', description: 'Writes and runs tests' }, - { name: 'reviewer', description: 'Reviews code and provides feedback' }, - ], - }, - dream: { - name: 'dream', - roles: [ - { name: 'dreamer', description: 'Generates ideas and explores possibilities' }, - { name: 'critic', description: 'Evaluates and refines ideas' }, - ], - }, - review: { - name: 'review', - roles: [ - { name: 'reviewer', description: 'Deep code review' }, - { name: 'security', description: 'Security-focused review' }, - ], - }, - fix: { - name: 'fix', - roles: [ - { name: 'debugger', description: 'Diagnoses and fixes bugs' }, - { name: 'verifier', description: 'Verifies fixes and writes regression tests' }, - ], - }, - debug: { - name: 'debug', - roles: [ - { name: 'investigator', description: 'Investigates root causes' }, - { name: 'reproducer', description: 'Creates minimal reproductions' }, - ], - }, -}; - // ============================================================================ // Paths // ============================================================================ @@ -103,62 +49,253 @@ function teamsDir(repoPath: string): string { return join(repoPath, '.genie', 'teams'); } +/** Sanitize team name for use as a filename (slashes become dashes). */ +function safeFileName(name: string): string { + return name.replace(/\//g, '--'); +} + function teamFilePath(repoPath: string, name: string): string { - const safeName = path.basename(name); + const safeName = safeFileName(path.basename(name) === name ? name : name); return join(teamsDir(repoPath), `${safeName}.json`); } +/** Resolve the worktree base directory from config. */ +function getWorktreeBase(repoPath: string): string { + const config = loadGenieConfigSync(); + const base = config.terminal?.worktreeBase ?? '.worktrees'; + // If relative, resolve against repo path + if (path.isAbsolute(base)) return base; + return join(repoPath, base); +} + // ============================================================================ -// API +// Agent Kill Helper // ============================================================================ -/** Get a blueprint by name. Returns null if not found. */ -export function getBlueprint(name: string): Blueprint | null { - return BLUEPRINTS[name] ?? null; +/** Best-effort kill all running workers matching a given agent name (by role or id). */ +async function killWorkersByName(agentName: string): Promise<void> { + const workers = await registry.list(); + const matches = workers.filter((w) => w.role === agentName || w.id === agentName); + for (const w of matches) { + try { + if (w.paneId && w.paneId !== 'inline') { + const { execSync } = require('node:child_process'); + execSync(`tmux kill-pane -t ${w.paneId}`, { stdio: 'ignore' }); + } + } catch { + // Pane may already be gone + } + await registry.unregister(w.id); + } } -/** List all available blueprint names. */ -export function listBlueprints(): string[] { - return Object.keys(BLUEPRINTS); +// ============================================================================ +// API +// ============================================================================ + +/** Ensure a git worktree exists for the given branch. */ +async function ensureWorktree( + repoPath: string, + branchName: string, + worktreePath: string, + baseBranch: string, +): Promise<void> { + // Pull latest on base branch (best-effort) + try { + await $`git -C ${repoPath} fetch origin ${baseBranch}`.quiet(); + } catch { + // Fetch may fail if remote doesn't exist or no network — continue + } + + await mkdir(path.dirname(worktreePath), { recursive: true }); + + // Skip if worktree already exists on disk + if (existsSync(worktreePath)) return; + + // Check if branch already exists + let branchExists = false; + try { + await $`git -C ${repoPath} rev-parse --verify ${branchName}`.quiet(); + branchExists = true; + } catch { + // Branch doesn't exist yet + } + + if (branchExists) { + await $`git -C ${repoPath} worktree add ${worktreePath} ${branchName}`.quiet(); + return; + } + + // Create new branch from baseBranch — try origin first, then local, then HEAD + try { + await $`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath} origin/${baseBranch}`.quiet(); + } catch { + try { + await $`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath} ${baseBranch}`.quiet(); + } catch { + await $`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath}`.quiet(); + } + } } -/** Create a new team from a blueprint. Auto-enables native teams when inside CC. */ -export async function createTeam(repoPath: string, name: string, blueprintName?: string): Promise<TeamConfig> { +/** + * Create a new team with a git worktree. + * + * Idempotent — if the team already exists, returns existing config. + * Steps: git pull on baseBranch → git worktree add → persist config. + */ +export async function createTeam(name: string, repo: string, baseBranch = 'dev'): Promise<TeamConfig> { + const repoPath = path.resolve(repo); const dir = teamsDir(repoPath); await mkdir(dir, { recursive: true }); + // Idempotent: return existing team if it already exists const filePath = teamFilePath(repoPath, name); if (existsSync(filePath)) { - throw new Error(`Team "${name}" already exists. Delete it first or choose a different name.`); + const content = await readFile(filePath, 'utf-8'); + return JSON.parse(content); } - const blueprint = blueprintName ? getBlueprint(blueprintName) : null; - if (blueprintName && !blueprint) { - const available = listBlueprints().join(', '); - throw new Error(`Blueprint "${blueprintName}" not found. Available: ${available}`); - } + const worktreeBase = getWorktreeBase(repoPath); + const worktreePath = join(worktreeBase, name); + + await ensureWorktree(repoPath, name, worktreePath, baseBranch); const now = new Date().toISOString(); const config: TeamConfig = { name, - blueprint: blueprintName, - roles: blueprint?.roles ?? [], + repo: repoPath, + baseBranch, + worktreePath, + members: [], createdAt: now, - updatedAt: now, }; // Auto-enable native teams when running inside Claude Code if (nativeTeamsManager.isInsideClaudeCode()) { config.nativeTeamsEnabled = true; - - const result = await nativeTeamsManager.registerAsTeamLead(name); - config.nativeTeamParentSessionId = result.sessionId; + try { + const result = await nativeTeamsManager.registerAsTeamLead(name); + config.nativeTeamParentSessionId = result.sessionId; + } catch { + // Best-effort — native team setup failure shouldn't block team creation + } } await writeFile(filePath, JSON.stringify(config, null, 2)); return config; } +/** + * Add an agent to a team's members list. + * + * Special case: if agentName is "council", hires all 10 built-in council members. + */ +export async function hireAgent(teamName: string, agentName: string, repoPath: string): Promise<string[]> { + const config = await getTeam(repoPath, teamName); + if (!config) { + throw new Error(`Team "${teamName}" not found.`); + } + + let added: string[]; + + if (agentName === 'council') { + // Hire all 10 council members + const councilNames = BUILTIN_COUNCIL_MEMBERS.map((m) => m.name); + added = councilNames.filter((n) => !config.members.includes(n)); + config.members.push(...added); + } else { + if (config.members.includes(agentName)) { + return []; // Already a member + } + config.members.push(agentName); + added = [agentName]; + } + + const filePath = teamFilePath(repoPath, teamName); + await writeFile(filePath, JSON.stringify(config, null, 2)); + return added; +} + +/** + * Remove an agent from a team's members list. + * Returns true if the agent was removed, false if not found. + */ +export async function fireAgent(teamName: string, agentName: string, repoPath: string): Promise<boolean> { + const config = await getTeam(repoPath, teamName); + if (!config) { + throw new Error(`Team "${teamName}" not found.`); + } + + const idx = config.members.indexOf(agentName); + if (idx === -1) return false; + + config.members.splice(idx, 1); + const filePath = teamFilePath(repoPath, teamName); + await writeFile(filePath, JSON.stringify(config, null, 2)); + + // Best-effort kill running agent + try { + await killWorkersByName(agentName); + } catch { + // Agent may not be running — ignore + } + + return true; +} + +/** + * Disband a team: remove git worktree and delete team config. + * Returns true if the team was found and disbanded. + */ +export async function disbandTeam(repoPath: string, teamName: string): Promise<boolean> { + const config = await getTeam(repoPath, teamName); + if (!config) return false; + + // Clean up native teams if enabled + if (config.nativeTeamsEnabled) { + try { + await nativeTeamsManager.deleteNativeTeam(teamName); + } catch { + // Best-effort + } + } + + // Kill all running team members + for (const member of config.members) { + try { + await killWorkersByName(member); + } catch { + // Best-effort — continue with other members + } + } + + // Remove git worktree + if (config.worktreePath && existsSync(config.worktreePath)) { + try { + await $`git -C ${repoPath} worktree remove ${config.worktreePath} --force`.quiet(); + } catch { + // Force-remove directory if git worktree remove fails + try { + await rm(config.worktreePath, { recursive: true, force: true }); + await $`git -C ${repoPath} worktree prune`.quiet(); + } catch { + // Best-effort + } + } + } + + // Delete team config file + const filePath = teamFilePath(repoPath, teamName); + try { + await unlink(filePath); + } catch { + return false; + } + + return true; +} + /** Get a team by name. Returns null if not found. */ export async function getTeam(repoPath: string, name: string): Promise<TeamConfig | null> { try { @@ -169,7 +306,7 @@ export async function getTeam(repoPath: string, name: string): Promise<TeamConfi } } -/** List all teams. */ +/** List all teams in a repo. */ export async function listTeams(repoPath: string): Promise<TeamConfig[]> { const dir = teamsDir(repoPath); try { @@ -190,24 +327,9 @@ export async function listTeams(repoPath: string): Promise<TeamConfig[]> { } } -/** Delete a team. Returns true if deleted, false if not found. */ -export async function deleteTeam(repoPath: string, name: string): Promise<boolean> { - const filePath = teamFilePath(repoPath, name); - - // Check if native teams were enabled and clean up - const config = await getTeam(repoPath, name); - if (config?.nativeTeamsEnabled) { - try { - await nativeTeamsManager.deleteNativeTeam(name); - } catch { - // Best-effort — native team cleanup failure shouldn't block local deletion - } - } - - try { - await unlink(filePath); - return true; - } catch { - return false; - } +/** List members of a team. Returns null if team not found. */ +export async function listMembers(repoPath: string, teamName: string): Promise<string[] | null> { + const config = await getTeam(repoPath, teamName); + if (!config) return null; + return config.members; } diff --git a/src/lib/team-shortcut.test.ts b/src/lib/team-shortcut.test.ts index d6eb07082..a63e83ece 100644 --- a/src/lib/team-shortcut.test.ts +++ b/src/lib/team-shortcut.test.ts @@ -15,7 +15,7 @@ const KNOWN_COMMANDS = new Set([ 'uninstall', '_open', 'shortcuts', - 'profiles', + 'dir', 'brainstorm', 'ledger', 'team', diff --git a/src/lib/tmux.ts b/src/lib/tmux.ts index d6e0bae6a..ec8acad2f 100644 --- a/src/lib/tmux.ts +++ b/src/lib/tmux.ts @@ -1,5 +1,3 @@ -import { v4 as uuidv4 } from 'uuid'; -import { shellQuote } from './team-lead-command.js'; import { executeTmux as wrapperExecuteTmux } from './tmux-wrapper.js'; // Basic interfaces for tmux objects @@ -24,21 +22,6 @@ interface TmuxPane { title: string; } -interface CommandExecution { - id: string; - paneId: string; - command: string; - status: 'pending' | 'completed' | 'error'; - startTime: Date; - result?: string; - exitCode?: number; - rawMode?: boolean; -} - -type ShellType = 'bash' | 'zsh' | 'fish'; - -const shellConfig: { type: ShellType } = { type: 'bash' }; - /** * Execute a tmux command and return the result */ @@ -92,33 +75,6 @@ export async function findSessionByName(name: string): Promise<TmuxSession | nul } } -/** - * Get a tmux environment variable for a specific window target. - * Uses `tmux show-environment -t <target> <varName>` which returns "VAR=value". - * Returns null if the variable is not set or the target doesn't exist. - */ -export async function getWindowEnv(target: string, varName: string): Promise<string | null> { - try { - const output = await executeTmux(`show-environment -t ${shellQuote(target)} ${shellQuote(varName)}`); - // Output format: "VARNAME=value" - const prefix = `${varName}=`; - if (output?.startsWith(prefix)) { - return output.slice(prefix.length).trim(); - } - return null; - } catch { - return null; - } -} - -/** - * Set a tmux environment variable scoped to a specific window target. - * Uses `tmux set-environment -t <target> <varName> <value>`. - */ -export async function setWindowEnv(target: string, varName: string, value: string): Promise<void> { - await executeTmux(`set-environment -t ${shellQuote(target)} ${shellQuote(varName)} ${shellQuote(value)}`); -} - /** * List windows in a session */ @@ -197,6 +153,7 @@ export async function capturePaneContent(paneId: string, lines = 200, includeCol /** * Create a new tmux session */ +/** @public - used in team-auto-spawn.ts (knip-ignored file) */ export async function createSession(name: string): Promise<TmuxSession | null> { await executeTmux(`new-session -d -s "${name}" -e LC_ALL=C.UTF-8 -e LANG=C.UTF-8`); return findSessionByName(name); @@ -366,35 +323,6 @@ async function rehydratePaneColorHook(windowId: string): Promise<void> { } } -/** - * Kill a tmux session by ID - */ -export async function killSession(sessionId: string): Promise<void> { - await executeTmux(`kill-session -t '${sessionId}'`); -} - -/** - * Kill a tmux window by ID - */ -export async function killWindow(windowId: string): Promise<void> { - await executeTmux(`kill-window -t '${windowId}'`); -} - -/** - * Kill a tmux window using session-qualified targeting. - * Uses `sessionId:windowId` format to avoid ambiguity across sessions. - */ -export async function killWindowQualified(sessionId: string, windowId: string): Promise<void> { - await executeTmux(`kill-window -t '${sessionId}:${windowId}'`); -} - -/** - * Kill a tmux pane by ID - */ -export async function killPane(paneId: string): Promise<void> { - await executeTmux(`kill-pane -t '${paneId}'`); -} - /** * Check if a tmux pane is still alive by attempting a minimal capture. * Returns false for invalid pane IDs ('inline', empty, non-%N format). @@ -409,164 +337,3 @@ export async function isPaneAlive(paneId: string): Promise<boolean> { return false; } } - -// Map to track ongoing command executions -const activeCommands = new Map<string, CommandExecution>(); - -const startMarkerText = 'TMUX_MCP_START'; -const endMarkerPrefix = 'TMUX_MCP_DONE_'; - -// Execute a command in a tmux pane and track its execution -export async function executeCommand( - paneId: string, - command: string, - rawMode?: boolean, - noEnter?: boolean, -): Promise<string> { - // Generate unique ID for this command execution - const commandId = uuidv4(); - - let fullCommand: string; - if (rawMode || noEnter) { - fullCommand = command; - } else { - const endMarkerText = getEndMarkerText(); - fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`; - } - - // Store command in tracking map - activeCommands.set(commandId, { - id: commandId, - paneId, - command, - status: 'pending', - startTime: new Date(), - rawMode: rawMode || noEnter, - }); - - // Send the command to the tmux pane - if (noEnter) { - // Check if this is a special key (e.g., Up, Down, Left, Right, Escape, Tab, etc.) - // Special keys in tmux are typically capitalized or have special names - const specialKeys = [ - 'Up', - 'Down', - 'Left', - 'Right', - 'Escape', - 'Tab', - 'Enter', - 'Space', - 'BSpace', - 'Delete', - 'Home', - 'End', - 'PageUp', - 'PageDown', - 'F1', - 'F2', - 'F3', - 'F4', - 'F5', - 'F6', - 'F7', - 'F8', - 'F9', - 'F10', - 'F11', - 'F12', - ]; - - if (specialKeys.includes(fullCommand)) { - // Send special key as-is - await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`); - } else { - // For regular text, send each character individually to ensure proper processing - // This handles both single characters (like 'q', 'f') and strings (like 'beam') - for (const char of fullCommand) { - await executeTmux(`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`); - } - } - } else { - await executeTmux(`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`); - } - - return commandId; -} - -function getEndMarkerText(): string { - return shellConfig.type === 'fish' ? `${endMarkerPrefix}$status` : `${endMarkerPrefix}$?`; -} - -/** - * Run a command synchronously in a tmux pane using wait-for. - * - * Uses tmux wait-for for proper synchronization (no polling). - * Output is captured via tee so it's visible in the pane AND returned. - * - * @returns {output: string, exitCode: number} - */ -export async function runCommandSync( - paneId: string, - command: string, - timeoutMs = 120000, -): Promise<{ output: string; exitCode: number }> { - const id = uuidv4().substring(0, 8); - const outFile = `/tmp/genie-${id}.out`; - const exitFile = `/tmp/genie-${id}.exit`; - const channel = `genie-${id}`; - - // Escape single quotes in command for shell embedding - const escapedCommand = command.replace(/'/g, "'\\''"); - - // Wrap command using tee for output capture: - // - Run command, pipe through tee (visible in terminal AND saved to file) - // - Capture exit code via PIPESTATUS - // - Signal completion via wait-for - const fullCommand = `{ ${escapedCommand}; } 2>&1 | tee ${outFile}; echo \${PIPESTATUS[0]} > ${exitFile}; tmux wait-for -S ${channel}`; - - // Send command to pane - await executeTmux(`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`); - - // Wait for completion (blocks until signaled, with timeout) - try { - await Promise.race([ - executeTmux(`wait-for ${channel}`), - new Promise((_, reject) => setTimeout(() => reject(new Error('Command timed out')), timeoutMs)), - ]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message === 'Command timed out') { - // Clean up on timeout - try { - await executeTmux(`wait-for -S ${channel}`); // Unblock any waiters - } catch {} - return { output: '', exitCode: 124 }; - } - throw error; - } - - // Read output and exit code from files - let output = ''; - let exitCode = 0; - - try { - const { readFile, unlink } = await import('node:fs/promises'); - - output = await readFile(outFile, 'utf-8'); - // Clean up output - output = output.trim(); - - const exitStr = await readFile(exitFile, 'utf-8'); - exitCode = Number.parseInt(exitStr.trim(), 10) || 0; - - // Clean up temp files - await unlink(outFile).catch(() => {}); - await unlink(exitFile).catch(() => {}); - } catch (err) { - // If files don't exist, command may have failed to start - console.error('Failed to read command output:', err); - } - - return { output, exitCode }; -} diff --git a/src/lib/wish-state.test.ts b/src/lib/wish-state.test.ts new file mode 100644 index 000000000..80ee934d3 --- /dev/null +++ b/src/lib/wish-state.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type GroupDefinition, completeGroup, createState, getGroupState, getState, startGroup } from './wish-state.js'; + +describe('wish-state', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'genie-wish-state-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + const sampleGroups: GroupDefinition[] = [ + { name: '1', dependsOn: [] }, + { name: '2', dependsOn: ['1'] }, + { name: '3', dependsOn: ['1'] }, + { name: '4', dependsOn: ['2', '3'] }, + ]; + + // ============================================================================ + // createState + // ============================================================================ + + describe('createState', () => { + test('creates state file with correct initial statuses', async () => { + const state = await createState('test-wish', sampleGroups, cwd); + + expect(state.wish).toBe('test-wish'); + expect(state.groups['1'].status).toBe('ready'); + expect(state.groups['2'].status).toBe('blocked'); + expect(state.groups['3'].status).toBe('blocked'); + expect(state.groups['4'].status).toBe('blocked'); + }); + + test('persists state file to disk', async () => { + await createState('test-wish', sampleGroups, cwd); + + const filePath = join(cwd, '.genie', 'state', 'test-wish.json'); + const content = await readFile(filePath, 'utf-8'); + const parsed = JSON.parse(content); + + expect(parsed.wish).toBe('test-wish'); + expect(parsed.groups['1'].status).toBe('ready'); + }); + + test('groups with no deps are ready', async () => { + const groups: GroupDefinition[] = [{ name: 'a' }, { name: 'b' }]; + const state = await createState('no-deps', groups, cwd); + + expect(state.groups.a.status).toBe('ready'); + expect(state.groups.b.status).toBe('ready'); + }); + + test('sets dependsOn array correctly', async () => { + const state = await createState('test-wish', sampleGroups, cwd); + + expect(state.groups['1'].dependsOn).toEqual([]); + expect(state.groups['2'].dependsOn).toEqual(['1']); + expect(state.groups['4'].dependsOn).toEqual(['2', '3']); + }); + }); + + // ============================================================================ + // startGroup + // ============================================================================ + + describe('startGroup', () => { + test('sets in_progress with assignee and timestamp', async () => { + await createState('test-wish', sampleGroups, cwd); + + const result = await startGroup('test-wish', '1', 'agent-a', cwd); + + expect(result.status).toBe('in_progress'); + expect(result.assignee).toBe('agent-a'); + expect(result.startedAt).toBeTruthy(); + }); + + test('persists state change to disk', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + + const state = await getState('test-wish', cwd); + expect(state?.groups['1'].status).toBe('in_progress'); + expect(state?.groups['1'].assignee).toBe('agent-a'); + }); + + test('refuses when dependencies not met', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(startGroup('test-wish', '2', 'agent-b', cwd)).rejects.toThrow( + 'dependency "1" is ready (must be done)', + ); + }); + + test('refuses when group already in progress', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + + await expect(startGroup('test-wish', '1', 'agent-b', cwd)).rejects.toThrow('already in progress'); + }); + + test('refuses when group already done', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await completeGroup('test-wish', '1', cwd); + + await expect(startGroup('test-wish', '1', 'agent-b', cwd)).rejects.toThrow('already done'); + }); + + test('refuses when group not found', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(startGroup('test-wish', 'nonexistent', 'agent-a', cwd)).rejects.toThrow('not found'); + }); + + test('allows start when all deps are done', async () => { + await createState('test-wish', sampleGroups, cwd); + + await startGroup('test-wish', '1', 'agent-a', cwd); + await completeGroup('test-wish', '1', cwd); + const result = await startGroup('test-wish', '2', 'agent-b', cwd); + + expect(result.status).toBe('in_progress'); + }); + }); + + // ============================================================================ + // completeGroup + // ============================================================================ + + describe('completeGroup', () => { + test('sets done with timestamp', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + + const result = await completeGroup('test-wish', '1', cwd); + + expect(result.status).toBe('done'); + expect(result.completedAt).toBeTruthy(); + }); + + test('recalculates dependent groups (blocked → ready)', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await completeGroup('test-wish', '1', cwd); + + const state = await getState('test-wish', cwd); + expect(state?.groups['2'].status).toBe('ready'); + expect(state?.groups['3'].status).toBe('ready'); + // Group 4 depends on 2 AND 3, so still blocked + expect(state?.groups['4'].status).toBe('blocked'); + }); + + test('unblocks multi-dependency group when all deps done', async () => { + await createState('test-wish', sampleGroups, cwd); + + // Complete group 1 + await startGroup('test-wish', '1', 'a', cwd); + await completeGroup('test-wish', '1', cwd); + + // Complete group 2 + await startGroup('test-wish', '2', 'b', cwd); + await completeGroup('test-wish', '2', cwd); + + // Complete group 3 + await startGroup('test-wish', '3', 'c', cwd); + await completeGroup('test-wish', '3', cwd); + + // Group 4 should now be ready + const state = await getState('test-wish', cwd); + expect(state?.groups['4'].status).toBe('ready'); + }); + + test('refuses when group already done', async () => { + await createState('test-wish', sampleGroups, cwd); + await startGroup('test-wish', '1', 'agent-a', cwd); + await completeGroup('test-wish', '1', cwd); + + await expect(completeGroup('test-wish', '1', cwd)).rejects.toThrow('already done'); + }); + + test('refuses when group not found', async () => { + await createState('test-wish', sampleGroups, cwd); + + await expect(completeGroup('test-wish', 'nonexistent', cwd)).rejects.toThrow('not found'); + }); + + test('rejects blocked groups', async () => { + await createState('test-wish', sampleGroups, cwd); + + // Group 2 depends on group 1 and should be blocked + const groupState = await getGroupState('test-wish', '2', cwd); + expect(groupState?.status).toBe('blocked'); + + await expect(completeGroup('test-wish', '2', cwd)).rejects.toThrow( + 'Cannot complete group "2": it is blocked (dependencies not met)', + ); + }); + + test('allows completing a ready group (skip in_progress)', async () => { + await createState('test-wish', sampleGroups, cwd); + + // Group 1 is ready, complete directly without starting + const result = await completeGroup('test-wish', '1', cwd); + expect(result.status).toBe('done'); + }); + }); + + // ============================================================================ + // getState / getGroupState + // ============================================================================ + + describe('getState', () => { + test('returns null for nonexistent slug', async () => { + const state = await getState('nonexistent', cwd); + expect(state).toBeNull(); + }); + + test('returns full state', async () => { + await createState('test-wish', sampleGroups, cwd); + const state = await getState('test-wish', cwd); + + expect(state).not.toBeNull(); + expect(state?.wish).toBe('test-wish'); + expect(Object.keys(state?.groups ?? {})).toEqual(['1', '2', '3', '4']); + }); + }); + + describe('getGroupState', () => { + test('returns null for nonexistent slug', async () => { + const group = await getGroupState('nonexistent', '1', cwd); + expect(group).toBeNull(); + }); + + test('returns null for nonexistent group', async () => { + await createState('test-wish', sampleGroups, cwd); + const group = await getGroupState('test-wish', 'nonexistent', cwd); + expect(group).toBeNull(); + }); + + test('returns single group state', async () => { + await createState('test-wish', sampleGroups, cwd); + const group = await getGroupState('test-wish', '1', cwd); + + expect(group).not.toBeNull(); + expect(group?.status).toBe('ready'); + }); + }); + + // ============================================================================ + // Full lifecycle + // ============================================================================ + + describe('full lifecycle', () => { + test('walks complete dependency chain', async () => { + await createState('test-wish', sampleGroups, cwd); + + // Group 1: ready → in_progress → done + await startGroup('test-wish', '1', 'impl-1', cwd); + await completeGroup('test-wish', '1', cwd); + + // Groups 2 and 3 now ready (parallel) + await startGroup('test-wish', '2', 'impl-2', cwd); + await startGroup('test-wish', '3', 'impl-3', cwd); + + await completeGroup('test-wish', '2', cwd); + await completeGroup('test-wish', '3', cwd); + + // Group 4 now ready + await startGroup('test-wish', '4', 'impl-4', cwd); + await completeGroup('test-wish', '4', cwd); + + // All done + const state = await getState('test-wish', cwd); + for (const group of Object.values(state?.groups ?? {})) { + expect(group.status).toBe('done'); + } + }); + }); +}); diff --git a/src/lib/wish-state.ts b/src/lib/wish-state.ts new file mode 100644 index 000000000..d482f3235 --- /dev/null +++ b/src/lib/wish-state.ts @@ -0,0 +1,297 @@ +/** + * Wish State Machine — Deterministic state tracking for wish execution groups. + * + * State file: `.genie/state/<slug>.json` in CWD (shared worktree). + * Only genie commands mutate state. Agents NEVER touch the state file. + * + * State transitions: + * blocked → ready (when all dependencies complete) + * ready → in_progress (via startGroup, checks deps) + * in_progress → done (via completeGroup, recalculates dependents) + */ + +import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +// ============================================================================ +// Schemas +// ============================================================================ + +export const GroupStatusSchema = z.enum(['blocked', 'ready', 'in_progress', 'done']); + +export const GroupStateSchema = z.object({ + status: GroupStatusSchema, + assignee: z.string().optional(), + dependsOn: z.array(z.string()).default([]), + startedAt: z.string().optional(), + completedAt: z.string().optional(), +}); +export type GroupState = z.infer<typeof GroupStateSchema>; + +export const WishStateSchema = z.object({ + wish: z.string(), + groups: z.record(z.string(), GroupStateSchema), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type WishState = z.infer<typeof WishStateSchema>; + +// ============================================================================ +// Group definition (input to createState) +// ============================================================================ + +export interface GroupDefinition { + name: string; + dependsOn?: string[]; +} + +// ============================================================================ +// File Locking — prevents concurrent state file races +// ============================================================================ + +const LOCK_TIMEOUT_MS = 5000; +const LOCK_RETRY_MS = 50; +const LOCK_STALE_MS = 10000; + +async function tryCleanStaleLock(lockPath: string): Promise<boolean> { + try { + const lockStat = await stat(lockPath); + if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) { + try { + await unlink(lockPath); + } catch { + /* race with other cleanup */ + } + return true; + } + } catch { + return true; // lock gone, retry + } + return false; +} + +async function tryCreateLock(lockPath: string): Promise<(() => Promise<void>) | null> { + try { + const handle = await open(lockPath, 'wx'); + await handle.writeFile(String(process.pid)); + await handle.close(); + return async () => { + try { + await unlink(lockPath); + } catch { + /* already removed */ + } + }; + } catch (err) { + const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + if (errCode !== 'EEXIST') throw err; + return null; + } +} + +async function acquireLock(statePath: string): Promise<() => Promise<void>> { + const lockPath = `${statePath}.lock`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + while (true) { + const release = await tryCreateLock(lockPath); + if (release) return release; + + const cleaned = await tryCleanStaleLock(lockPath); + if (cleaned) continue; + + if (Date.now() > deadline) { + try { + await unlink(lockPath); + } catch { + throw new Error(`State lock timeout: could not remove stale lock at ${lockPath}`); + } + continue; + } + await new Promise((r) => setTimeout(r, LOCK_RETRY_MS)); + } +} + +async function withStateLock<T>(statePath: string, fn: (state: WishState) => T | Promise<T>): Promise<T> { + const release = await acquireLock(statePath); + try { + const state = await loadState(statePath); + if (!state) throw new Error(`State file not found: ${statePath}`); + const result = await fn(state); + await saveState(statePath, state); + return result; + } finally { + await release(); + } +} + +// ============================================================================ +// File I/O +// ============================================================================ + +function getStatePath(slug: string, cwd?: string): string { + return join(cwd ?? process.cwd(), '.genie', 'state', `${slug}.json`); +} + +async function loadState(statePath: string): Promise<WishState | null> { + try { + const content = await readFile(statePath, 'utf-8'); + return WishStateSchema.parse(JSON.parse(content)); + } catch { + return null; + } +} + +async function saveState(statePath: string, state: WishState): Promise<void> { + await mkdir(dirname(statePath), { recursive: true }); + state.updatedAt = new Date().toISOString(); + await writeFile(statePath, JSON.stringify(state, null, 2)); +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** + * Recalculate statuses of groups that depend on the completed group. + * A group transitions from `blocked` to `ready` when ALL its dependencies are `done`. + */ +function recalculateDependents(state: WishState): void { + for (const [, group] of Object.entries(state.groups)) { + if (group.status !== 'blocked') continue; + if (group.dependsOn.length === 0) continue; + + const allDepsDone = group.dependsOn.every((dep) => { + const depGroup = state.groups[dep]; + return depGroup?.status === 'done'; + }); + + if (allDepsDone) { + group.status = 'ready'; + } + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Initialize state file from wish group definitions. + * Groups with no dependencies start as `ready`. Others start as `blocked`. + */ +export async function createState(slug: string, groups: GroupDefinition[], cwd?: string): Promise<WishState> { + const statePath = getStatePath(slug, cwd); + await mkdir(dirname(statePath), { recursive: true }); + + const now = new Date().toISOString(); + const groupEntries: Record<string, GroupState> = {}; + + for (const group of groups) { + const deps = group.dependsOn ?? []; + groupEntries[group.name] = { + status: deps.length === 0 ? 'ready' : 'blocked', + dependsOn: deps, + }; + } + + const state: WishState = { + wish: slug, + groups: groupEntries, + createdAt: now, + updatedAt: now, + }; + + await saveState(statePath, state); + return state; +} + +/** + * Transition a group to `in_progress`. + * Refuses if any dependency is not `done`. + * Returns the updated group state, or throws on failure. + */ +export async function startGroup(slug: string, groupName: string, assignee: string, cwd?: string): Promise<GroupState> { + const statePath = getStatePath(slug, cwd); + + return withStateLock(statePath, (state) => { + const group = state.groups[groupName]; + if (!group) { + throw new Error(`Group "${groupName}" not found in wish "${slug}"`); + } + + if (group.status === 'in_progress') { + throw new Error(`Group "${groupName}" is already in progress (assigned to ${group.assignee ?? 'unknown'})`); + } + + if (group.status === 'done') { + throw new Error(`Group "${groupName}" is already done`); + } + + // Check dependencies + for (const dep of group.dependsOn) { + const depGroup = state.groups[dep]; + if (!depGroup) { + throw new Error(`Dependency "${dep}" not found in wish "${slug}"`); + } + if (depGroup.status !== 'done') { + throw new Error(`Cannot start group "${groupName}": dependency "${dep}" is ${depGroup.status} (must be done)`); + } + } + + group.status = 'in_progress'; + group.assignee = assignee; + group.startedAt = new Date().toISOString(); + + return { ...group }; + }); +} + +/** + * Transition a group to `done`. + * Recalculates dependent groups (blocked → ready when all deps done). + */ +export async function completeGroup(slug: string, groupName: string, cwd?: string): Promise<GroupState> { + const statePath = getStatePath(slug, cwd); + + return withStateLock(statePath, (state) => { + const group = state.groups[groupName]; + if (!group) { + throw new Error(`Group "${groupName}" not found in wish "${slug}"`); + } + + if (group.status === 'done') { + throw new Error(`Group "${groupName}" is already done`); + } + + if (group.status === 'blocked') { + throw new Error(`Cannot complete group "${groupName}": it is blocked (dependencies not met)`); + } + + group.status = 'done'; + group.completedAt = new Date().toISOString(); + + // Recalculate dependents + recalculateDependents(state); + + return { ...group }; + }); +} + +/** + * Read current state for a wish. + */ +export async function getState(slug: string, cwd?: string): Promise<WishState | null> { + const statePath = getStatePath(slug, cwd); + return loadState(statePath); +} + +/** + * Read a single group's state. + */ +export async function getGroupState(slug: string, groupName: string, cwd?: string): Promise<GroupState | null> { + const state = await getState(slug, cwd); + if (!state) return null; + return state.groups[groupName] ?? null; +} diff --git a/src/lib/wish-tasks.ts b/src/lib/wish-tasks.ts deleted file mode 100644 index 9e804861c..000000000 --- a/src/lib/wish-tasks.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Wish-Task Linking - * - * Manages the relationship between wishes and beads tasks. - * Stores links in .genie/wishes/<slug>/tasks.json - */ - -import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -// ============================================================================ -// Types -// ============================================================================ - -interface LinkedTask { - /** Beads task ID (e.g., "bd-42") */ - id: string; - /** Task title */ - title: string; - /** Task status */ - status: 'open' | 'in_progress' | 'done' | 'blocked'; - /** When the link was created */ - linkedAt: string; -} - -interface WishTasksFile { - /** Wish slug */ - wishId: string; - /** Linked tasks */ - tasks: LinkedTask[]; - /** Last updated */ - updatedAt: string; -} - -// ============================================================================ -// Path Utilities -// ============================================================================ - -/** - * Get the tasks file path for a wish - */ -function getWishTasksPath(repoPath: string, wishSlug: string): string { - return join(repoPath, '.genie', 'wishes', wishSlug, 'tasks.json'); -} - -/** - * Check if a wish exists - */ -export async function wishExists(repoPath: string, wishSlug: string): Promise<boolean> { - const wishPath = join(repoPath, '.genie', 'wishes', wishSlug, 'wish.md'); - try { - await access(wishPath); - return true; - } catch { - return false; - } -} - -// ============================================================================ -// CRUD Operations -// ============================================================================ - -/** - * Load wish tasks file - */ -async function loadWishTasks(repoPath: string, wishSlug: string): Promise<WishTasksFile> { - const filePath = getWishTasksPath(repoPath, wishSlug); - - try { - const content = await readFile(filePath, 'utf-8'); - return JSON.parse(content); - } catch { - return { - wishId: wishSlug, - tasks: [], - updatedAt: new Date().toISOString(), - }; - } -} - -/** - * Save wish tasks file - */ -async function saveWishTasks(repoPath: string, wishSlug: string, data: WishTasksFile): Promise<void> { - const filePath = getWishTasksPath(repoPath, wishSlug); - const dir = join(repoPath, '.genie', 'wishes', wishSlug); - - // Ensure directory exists - await mkdir(dir, { recursive: true }); - - data.updatedAt = new Date().toISOString(); - await writeFile(filePath, JSON.stringify(data, null, 2)); -} - -/** - * Link a task to a wish - */ -export async function linkTask( - repoPath: string, - wishSlug: string, - taskId: string, - taskTitle: string, - status: LinkedTask['status'] = 'open', -): Promise<void> { - const data = await loadWishTasks(repoPath, wishSlug); - - // Check if already linked - const existing = data.tasks.find((t) => t.id === taskId); - if (existing) { - // Update existing - existing.title = taskTitle; - existing.status = status; - } else { - // Add new - data.tasks.push({ - id: taskId, - title: taskTitle, - status, - linkedAt: new Date().toISOString(), - }); - } - - await saveWishTasks(repoPath, wishSlug, data); -} - -/** - * Unlink a task from a wish - */ -export async function unlinkTask(repoPath: string, wishSlug: string, taskId: string): Promise<boolean> { - const data = await loadWishTasks(repoPath, wishSlug); - - const index = data.tasks.findIndex((t) => t.id === taskId); - if (index === -1) { - return false; - } - - data.tasks.splice(index, 1); - await saveWishTasks(repoPath, wishSlug, data); - return true; -} diff --git a/src/lib/worktree-manager.test.ts b/src/lib/worktree-manager.test.ts index 3304455f9..158f7ee26 100644 --- a/src/lib/worktree-manager.test.ts +++ b/src/lib/worktree-manager.test.ts @@ -10,13 +10,7 @@ import { join } from 'node:path'; import { $ } from 'bun'; // Will be imported after implementation -import { - BeadsWorktreeManager, - GitWorktreeManager, - type WorktreeInfo, - getWorktreeManager, - isBdAvailable, -} from './worktree-manager.js'; +import { GitWorktreeManager, type WorktreeInfo, getWorktreeManager } from './worktree-manager.js'; // ============================================================================ // Test Setup @@ -175,26 +169,6 @@ describe('GitWorktreeManager', () => { }); }); -// ============================================================================ -// BeadsWorktreeManager Tests (mocked) -// ============================================================================ - -describe('BeadsWorktreeManager', () => { - // These tests mock bd since it may not be available - test('create() should call bd worktree create', async () => { - // Skip if bd not available - if (!(await isBdAvailable())) { - console.log('Skipping BeadsWorktreeManager tests - bd not available'); - return; - } - - // If bd is available, this would test actual bd integration - // For now we just verify the class exists - const manager = new BeadsWorktreeManager(); - expect(manager).toBeDefined(); - }); -}); - // ============================================================================ // Factory Function Tests // ============================================================================ @@ -203,7 +177,6 @@ describe('getWorktreeManager', () => { test('should return a WorktreeManagerInterface', async () => { const manager = await getWorktreeManager(TEST_REPO); - // Verify it implements the interface expect(typeof manager.create).toBe('function'); expect(typeof manager.remove).toBe('function'); expect(typeof manager.list).toBe('function'); @@ -214,20 +187,4 @@ describe('getWorktreeManager', () => { const manager = new GitWorktreeManager(TEST_REPO); expect(manager).toBeInstanceOf(GitWorktreeManager); }); - - test('BeadsWorktreeManager can be created directly', () => { - const manager = new BeadsWorktreeManager(); - expect(manager).toBeInstanceOf(BeadsWorktreeManager); - }); -}); - -// ============================================================================ -// isBdAvailable Tests -// ============================================================================ - -describe('isBdAvailable', () => { - test('should return boolean', async () => { - const result = await isBdAvailable(); - expect(typeof result).toBe('boolean'); - }); }); diff --git a/src/lib/worktree-manager.ts b/src/lib/worktree-manager.ts index 549edfcb5..450874e4b 100644 --- a/src/lib/worktree-manager.ts +++ b/src/lib/worktree-manager.ts @@ -1,9 +1,5 @@ /** - * Worktree Manager - Unified interface for git worktree management - * - * Provides two implementations: - * - GitWorktreeManager: Direct git worktree commands (fallback, always works) - * - BeadsWorktreeManager: Uses bd worktree commands when available + * Worktree Manager - Git worktree management * * Worktrees are created in .genie/worktrees/<wish-id>/ with branch work/<wish-id> */ @@ -16,51 +12,18 @@ import { $ } from 'bun'; // Types // ============================================================================ -/** - * Information about a worktree - */ export interface WorktreeInfo { - /** Absolute path to the worktree directory */ path: string; - /** Git branch name (e.g., work/wish-1) */ branch: string; - /** Wish ID (e.g., wish-1) */ wishId: string; - /** Git commit hash (optional) */ commitHash?: string; - /** When the worktree was created (optional) */ createdAt?: Date; } -/** - * Unified interface for worktree management - */ interface WorktreeManagerInterface { - /** - * Create a new worktree for a wish - * @param wishId - The wish identifier (e.g., "wish-1") - * @param repoPath - Path to the git repository - * @returns Information about the created worktree - */ create(wishId: string, repoPath: string): Promise<WorktreeInfo>; - - /** - * Remove a worktree - * @param wishId - The wish identifier - */ remove(wishId: string): Promise<void>; - - /** - * List all worktrees managed by this instance - * @returns Array of worktree information - */ list(): Promise<WorktreeInfo[]>; - - /** - * Get information about a specific worktree - * @param wishId - The wish identifier - * @returns Worktree info or null if not found - */ get(wishId: string): Promise<WorktreeInfo | null>; } @@ -74,88 +37,22 @@ const WORKTREE_DIR_NAME = '.genie/worktrees'; // Helper Functions // ============================================================================ -/** - * Check if bd CLI is available on PATH - */ -export async function isBdAvailable(): Promise<boolean> { - try { - // Use Bun.which if available, otherwise try running bd - const BunExt = Bun as unknown as { which?: (name: string) => string | null }; - if (typeof BunExt.which === 'function') { - return Boolean(BunExt.which('bd')); - } - await $`bd --version`.quiet(); - return true; - } catch { - return false; - } -} - -/** - * Run bd command and parse output - */ -async function runBd(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { - try { - const result = await $`bd ${args}`.quiet(); - return { - stdout: result.stdout.toString().trim(), - stderr: result.stderr.toString().trim(), - exitCode: 0, - }; - } catch (error) { - const shellErr = error as { stdout?: Buffer; stderr?: Buffer; exitCode?: number }; - return { - stdout: shellErr.stdout?.toString().trim() || '', - stderr: shellErr.stderr?.toString().trim() || '', - exitCode: shellErr.exitCode || 1, - }; - } -} - -/** - * Parse JSON safely - */ -function parseJson<T>(output: string): T | null { - if (!output) return null; - try { - return JSON.parse(output); - } catch { - return null; - } -} - -/** - * Get worktree base directory for a repo - */ function getWorktreeBaseDir(repoPath: string): string { return join(repoPath, WORKTREE_DIR_NAME); } -/** - * Get worktree path for a wish - */ function getWorktreePath(repoPath: string, wishId: string): string { return join(getWorktreeBaseDir(repoPath), wishId); } -/** - * Get branch name for a wish - */ function getBranchName(wishId: string): string { return `work/${wishId}`; } // ============================================================================ -// GitWorktreeManager - Direct git worktree implementation +// GitWorktreeManager // ============================================================================ -/** - * Git-based worktree manager (fallback implementation) - * - * Uses `git worktree add -b work/<wish-id>` directly. - * Worktrees are created in .genie/worktrees/<wish-id>/ under the target repo. - * Creates a redirect file so bd commands work in the worktree. - */ export class GitWorktreeManager implements WorktreeManagerInterface { private repoPath: string; @@ -163,26 +60,19 @@ export class GitWorktreeManager implements WorktreeManagerInterface { this.repoPath = repoPath; } - /** - * Create a new worktree for a wish - */ async create(wishId: string, repoPath: string): Promise<WorktreeInfo> { const worktreePath = getWorktreePath(repoPath, wishId); const branchName = getBranchName(wishId); - // Ensure .genie/worktrees directory exists await mkdir(getWorktreeBaseDir(repoPath), { recursive: true }); - // Check if worktree already exists try { await access(worktreePath); - // Worktree exists, return info return (await this.getWorktreeInfo(wishId, repoPath)) as WorktreeInfo; } catch { // Doesn't exist, will create } - // Check if branch already exists let branchExists = false; try { await $`git -C ${repoPath} rev-parse --verify ${branchName}`.quiet(); @@ -191,21 +81,16 @@ export class GitWorktreeManager implements WorktreeManagerInterface { // Branch doesn't exist } - // Create worktree with new or existing branch if (branchExists) { - // Use existing branch await $`git -C ${repoPath} worktree add ${worktreePath} ${branchName}`.quiet(); } else { - // Create new branch await $`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath}`.quiet(); } - // Create .genie/redirect file so bd commands work in the worktree const genieDir = join(worktreePath, '.genie'); await mkdir(genieDir, { recursive: true }); await writeFile(join(genieDir, 'redirect'), join(repoPath, '.genie')); - // Get commit hash let commitHash: string | undefined; try { const result = await $`git -C ${worktreePath} rev-parse HEAD`.quiet(); @@ -223,25 +108,19 @@ export class GitWorktreeManager implements WorktreeManagerInterface { }; } - /** - * Remove a worktree - */ async remove(wishId: string): Promise<void> { const worktreePath = getWorktreePath(this.repoPath, wishId); try { - // Remove worktree via git await $`git -C ${this.repoPath} worktree remove ${worktreePath} --force`.quiet(); } catch { - // Try manual cleanup if git fails try { await rm(worktreePath, { recursive: true, force: true }); } catch { - // Ignore - may not exist + // Ignore } } - // Prune stale worktree references try { await $`git -C ${this.repoPath} worktree prune`.quiet(); } catch { @@ -249,9 +128,6 @@ export class GitWorktreeManager implements WorktreeManagerInterface { } } - /** - * List all worktrees - */ async list(): Promise<WorktreeInfo[]> { const result = await $`git -C ${this.repoPath} worktree list --porcelain`.quiet(); const output = result.stdout.toString(); @@ -263,7 +139,6 @@ export class GitWorktreeManager implements WorktreeManagerInterface { for (const line of output.split('\n')) { if (line.startsWith('worktree ')) { if (current.path?.startsWith(baseDir)) { - // Extract wishId from path current.wishId = basename(current.path); worktrees.push(current as WorktreeInfo); } @@ -275,7 +150,6 @@ export class GitWorktreeManager implements WorktreeManagerInterface { } } - // Don't forget the last worktree if (current.path?.startsWith(baseDir)) { current.wishId = basename(current.path); worktrees.push(current as WorktreeInfo); @@ -284,36 +158,27 @@ export class GitWorktreeManager implements WorktreeManagerInterface { return worktrees; } - /** - * Get information about a specific worktree - */ async get(wishId: string): Promise<WorktreeInfo | null> { return this.getWorktreeInfo(wishId, this.repoPath); } - /** - * Internal helper to get worktree info - */ private async getWorktreeInfo(wishId: string, repoPath: string): Promise<WorktreeInfo | null> { const worktreePath = getWorktreePath(repoPath, wishId); - // Check if path exists try { await access(worktreePath); } catch { return null; } - // Get branch name let branch = getBranchName(wishId); try { const result = await $`git -C ${worktreePath} branch --show-current`.quiet(); branch = result.stdout.toString().trim() || branch; } catch { - // Use default branch name + // Use default } - // Get commit hash let commitHash: string | undefined; try { const result = await $`git -C ${worktreePath} rev-parse HEAD`.quiet(); @@ -322,118 +187,14 @@ export class GitWorktreeManager implements WorktreeManagerInterface { // Ignore } - return { - path: worktreePath, - branch, - wishId, - commitHash, - }; + return { path: worktreePath, branch, wishId, commitHash }; } } // ============================================================================ -// BeadsWorktreeManager - bd worktree implementation +// Factory // ============================================================================ -/** - * Beads-based worktree manager - * - * Uses `bd worktree create/remove/list` when bd is available. - * Wraps beads-registry functions for worktree management. - */ -export class BeadsWorktreeManager implements WorktreeManagerInterface { - /** - * Create a new worktree for a wish - */ - async create(wishId: string, _repoPath: string): Promise<WorktreeInfo> { - const { stdout, exitCode, stderr } = await runBd(['worktree', 'create', wishId, '--json']); - - if (exitCode !== 0) { - throw new Error(`bd worktree create failed: ${stderr || stdout}`); - } - - const info = parseJson<{ path: string; branch: string; name: string }>(stdout); - - if (!info) { - throw new Error('Failed to parse bd worktree create output'); - } - - // FIX: Ensure branch uses work/ prefix - const expectedBranch = getBranchName(wishId); - if (info.branch !== expectedBranch) { - console.log(`🔧 Fixing branch name: ${info.branch} → ${expectedBranch}`); - await $`git -C ${info.path} branch -m ${expectedBranch}`.quiet(); - info.branch = expectedBranch; - } - - return { - path: info.path, - branch: info.branch, - wishId, - }; - } - - /** - * Remove a worktree - */ - async remove(wishId: string): Promise<void> { - const { exitCode, stderr } = await runBd(['worktree', 'remove', wishId]); - - if (exitCode !== 0) { - throw new Error(`bd worktree remove failed: ${stderr}`); - } - } - - /** - * List all worktrees - */ - async list(): Promise<WorktreeInfo[]> { - const { stdout, exitCode } = await runBd(['worktree', 'list', '--json']); - - if (exitCode !== 0 || !stdout) { - return []; - } - - const worktrees = parseJson<Array<{ path: string; branch: string; name: string }>>(stdout); - - if (!worktrees) { - return []; - } - - return worktrees.map((wt) => ({ - path: wt.path, - branch: wt.branch, - wishId: wt.name, - })); - } - - /** - * Get information about a specific worktree - */ - async get(wishId: string): Promise<WorktreeInfo | null> { - const worktrees = await this.list(); - return ( - worktrees.find((wt) => wt.wishId === wishId || wt.branch === wishId || wt.branch === getBranchName(wishId)) || - null - ); - } -} - -// ============================================================================ -// Factory Function -// ============================================================================ - -/** - * Get the appropriate worktree manager for the given repo - * - * Returns BeadsWorktreeManager if bd is available, otherwise GitWorktreeManager. - * - * @param repoPath - Path to the git repository - * @returns The appropriate WorktreeManager implementation - */ export async function getWorktreeManager(repoPath: string): Promise<WorktreeManagerInterface> { - if (await isBdAvailable()) { - return new BeadsWorktreeManager(); - } return new GitWorktreeManager(repoPath); } diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index a719e3fb3..f0f46bbfa 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -1,30 +1,19 @@ /** - * Agent Namespace — unified agent lifecycle commands. + * Agent lifecycle — top-level command handlers. * - * genie agent: - * spawn - Spawn an agent with provider selection - * list - List all agents with provider metadata - * kill <id> - Force kill an agent - * dashboard - Live status of all agents - * suspend - Suspend an agent (kill pane, preserve session) - * watchdog - Idle timeout watchdog - * approve - Auto-approve engine management - * history - Compressed session catch-up - * answer - Answer agent question - * events - Stream Claude Code events - * close - Close task and cleanup agent - * ship - Mark done, merge, cleanup - * read - Read agent pane output - * exec - Execute command in agent pane + * Exported handlers (registered in genie.ts as top-level commands): + * handleWorkerSpawn - genie spawn <name> + * handleWorkerKill - genie kill <name> + * handleWorkerStop - genie stop <name> + * handleLsCommand - genie ls */ -import type { Command } from 'commander'; +import * as directory from '../lib/agent-directory.js'; import * as registry from '../lib/agent-registry.js'; +import { getBuiltin } from '../lib/builtin-agents.js'; import * as nativeTeams from '../lib/claude-native-teams.js'; import { OTEL_RELAY_PORT, ensureCodexOtelConfig } from '../lib/codex-config.js'; -import * as mailbox from '../lib/mailbox.js'; import { buildLayoutCommand, resolveLayoutMode } from '../lib/mosaic-layout.js'; -import { detectState } from '../lib/orchestrator/index.js'; import { type ClaudeTeamColor, type ProviderName, @@ -35,50 +24,11 @@ import { import * as teamManager from '../lib/team-manager.js'; import * as tmux from '../lib/tmux.js'; import { isPaneAlive } from '../lib/tmux.js'; -import * as approveCmd from './approve.js'; -import * as closeCmd from './close.js'; -import * as eventsCmd from './events.js'; -import * as execCmd from './exec.js'; -import * as historyCmd from './history.js'; -import * as orchestrateCmd from './orchestrate.js'; -import * as readCmd from './read.js'; -import * as shipCmd from './ship.js'; // ============================================================================ -// Helper Functions (legacy) +// Helper Functions // ============================================================================ -/** - * Get current state from pane output - */ -async function getCurrentState(paneId: string): Promise<string> { - try { - const output = await tmux.capturePaneContent(paneId, 30); - const state = detectState(output); - - // Map to display format - switch (state.type) { - case 'working': - case 'tool_use': - return 'working'; - case 'idle': - return 'idle'; - case 'permission': - return '⚠️ perm'; - case 'question': - return '⚠️ question'; - case 'error': - return '❌ error'; - case 'complete': - return '✅ done'; - default: - return state.type; - } - } catch { - return 'unknown'; - } -} - /** Check if a process is alive by PID file. */ function isRelayAlive(pidFile: string): boolean { const { readFileSync, existsSync } = require('node:fs'); @@ -408,39 +358,10 @@ process.on('SIGINT', () => { server.close(); process.exit(0); }); } } -/** - * Map display state to registry state - */ -function mapDisplayStateToRegistry(displayState: string): registry.AgentState | null { - if (displayState === 'working') return 'working'; - if (displayState === 'idle') return 'idle'; - if (displayState === '⚠️ perm') return 'permission'; - if (displayState === '⚠️ question') return 'question'; - if (displayState === '❌ error') return 'error'; - if (displayState === '✅ done') return 'done'; - return null; -} - // ============================================================================ // Helper: Generate Worker ID (teams) // ============================================================================ -async function getLastMessageTime(w: registry.Agent): Promise<string | null> { - try { - const repoPath = w.repoPath ?? process.cwd(); - const messages = await mailbox.inbox(repoPath, w.id); - if (messages.length === 0) return null; - const last = messages[messages.length - 1]; - const ago = Date.now() - new Date(last.createdAt).getTime(); - const mins = Math.floor(ago / 60000); - if (mins < 1) return '<1m ago'; - if (mins < 60) return `${mins}m ago`; - return `${Math.floor(mins / 60)}h ago`; - } catch { - return null; - } -} - async function generateWorkerId(team: string, role?: string): Promise<string> { const base = role ? `${team}-${role}` : team; const existing = await registry.list(); @@ -720,10 +641,10 @@ async function resolveNativeTeam( return { parentSessionId, spawnColor, nativeTeam }; } -async function handleWorkerSpawn(options: { +export interface SpawnOptions { provider: string; team: string; - role?: string; + model?: string; skill?: string; layout?: string; color?: string; @@ -731,32 +652,70 @@ async function handleWorkerSpawn(options: { permissionMode?: string; extraArgs?: string[]; cwd?: string; -}): Promise<void> { - // 1. Resolve team name from flag, env, or session discovery - const team = options.team || (await nativeTeams.discoverTeamName()); - if (!team) { - console.error('Error: --team is required (or set GENIE_TEAM, or run inside a genie session)'); +} + +/** Resolve agent from directory, returning entry + derived CWD/identity/model/systemPrompt. */ +async function resolveAgentForSpawn( + name: string, + options: SpawnOptions, +): Promise<{ + entry: directory.DirectoryEntry; + repoPath: string; + identityPath: string | null; + model: string | undefined; + systemPrompt: string | undefined; +}> { + const resolved = await directory.resolve(name); + if (!resolved) { + console.error(`Error: Agent "${name}" not found in directory or built-ins.`); + console.error(` Register with: genie dir add ${name} --dir <path>`); + console.error(' Or use a built-in: implementor, tester, reviewer, debugger, ...'); process.exit(1); } + const entry = resolved.entry; + + // For built-in agents, look up their inline system prompt + let systemPrompt: string | undefined; + if (resolved.builtin) { + const builtin = getBuiltin(name); + systemPrompt = builtin?.systemPrompt; + } - // 1a. Reject if a live worker with the same role already exists in this team - if (options.role) await rejectDuplicateRole(team, options.role); + return { + entry, + repoPath: options.cwd ?? (entry.dir || undefined) ?? process.cwd(), + identityPath: entry.dir ? directory.loadIdentity(entry) : null, + model: options.model ?? entry.model, + systemPrompt, + }; +} - // 1b. Validate spawn parameters (Group A contract) +/** Build SpawnParams from resolved agent + options. */ +async function buildSpawnParams( + name: string, + team: string, + options: SpawnOptions, + agent: Awaited<ReturnType<typeof resolveAgentForSpawn>>, +): Promise<{ params: SpawnParams; parentSessionId: string; spawnColor: ClaudeTeamColor }> { const params: SpawnParams = { provider: options.provider as ProviderName, team, - role: options.role, + role: name, skill: options.skill, extraArgs: options.extraArgs, + model: agent.model, + systemPromptFile: agent.identityPath ?? undefined, + systemPrompt: agent.systemPrompt, + promptMode: agent.entry.promptMode, }; - // 2. Ensure native team infrastructure + Claude flags. - const repoPath = options.cwd ?? process.cwd(); - const { parentSessionId, spawnColor, nativeTeam } = await resolveNativeTeam(team, repoPath, options); + const { parentSessionId, spawnColor, nativeTeam } = await resolveNativeTeam(team, agent.repoPath, { + ...options, + role: name, + }); if (nativeTeam) params.nativeTeam = nativeTeam; - // 2b. Inject hook dispatch into team settings.json (idempotent) + // Inject hook dispatch into team settings.json (idempotent) try { const { injectTeamHooks } = await import('../hooks/inject.js'); const injected = await injectTeamHooks(team); @@ -765,19 +724,36 @@ async function handleWorkerSpawn(options: { console.warn(`Warning: could not inject hooks for team "${team}": ${err instanceof Error ? err.message : err}`); } - // Session ID only for Claude const claudeSessionId = params.provider === 'claude' ? crypto.randomUUID() : undefined; if (claudeSessionId) params.sessionId = claudeSessionId; + return { params, parentSessionId, spawnColor }; +} + +export async function handleWorkerSpawn(name: string, options: SpawnOptions): Promise<void> { + // 1. Resolve agent from directory or built-ins + const agent = await resolveAgentForSpawn(name, options); + + // 2. Resolve team + const team = options.team || (await nativeTeams.discoverTeamName()); + if (!team) { + console.error('Error: --team is required (or set GENIE_TEAM, or run inside a genie session)'); + process.exit(1); + } + await rejectDuplicateRole(team, name); + + // 3. Build params + const { params, parentSessionId, spawnColor } = await buildSpawnParams(name, team, options, agent); + const validated = validateSpawnParams(params); const launch = buildLaunchCommand(validated); const layoutMode = resolveLayoutMode(options.layout); - const workerId = await generateWorkerId(validated.team, validated.role); + const workerId = await generateWorkerId(validated.team, name); const insideTmux = Boolean(process.env.TMUX); const nt = validated.nativeTeam; const now = new Date().toISOString(); - const agentName = nt?.agentName ?? validated.role ?? 'worker'; + const agentName = nt?.agentName ?? name; // OTel relay for non-native workers (Codex) let otelRelayActive = false; @@ -797,12 +773,12 @@ async function handleWorkerSpawn(options: { agentName, spawnColor, parentSessionId, - claudeSessionId, + claudeSessionId: params.sessionId, otelRelayActive, now, transport: insideTmux ? 'tmux' : 'inline', extraArgs: options.extraArgs, - cwd: repoPath, + cwd: agent.repoPath, }; if (insideTmux) { @@ -813,26 +789,9 @@ async function handleWorkerSpawn(options: { } // ============================================================================ -// List helpers (extracted for cognitive complexity) +// Kill helpers // ============================================================================ -type WorkerListEntry = { worker: registry.Agent; liveState: string; lastMsg: string | null; dead: boolean }; -type StoppedEntry = { template: registry.WorkerTemplate }; - -/** Find templates with no corresponding active worker. */ -async function collectStoppedTemplates(activeEntries: WorkerListEntry[]): Promise<StoppedEntry[]> { - const templates = await registry.listTemplates(); - const activeIds = new Set(activeEntries.map((e) => e.worker.id)); - const activeRoles = new Set(activeEntries.map((e) => e.worker.role).filter(Boolean)); - const stopped: StoppedEntry[] = []; - for (const t of templates) { - if (!activeIds.has(t.id) && !(t.role && activeRoles.has(t.role))) { - stopped.push({ template: t }); - } - } - return stopped; -} - /** Clean up a worker's native team registration. */ async function cleanupWorkerNativeTeam(w: registry.Agent): Promise<void> { if (!w.team || !w.nativeAgentId) return; @@ -841,185 +800,6 @@ async function cleanupWorkerNativeTeam(w: registry.Agent): Promise<void> { await nativeTeams.unregisterNativeMember(w.team, agentName).catch(() => {}); } -/** Process a single worker for the list view: returns entry or pruned ID. */ -async function processWorkerForList( - w: registry.Agent, - prune?: boolean, -): Promise<{ entry?: WorkerListEntry; prunedId?: string }> { - if (w.state === 'suspended') { - return processSuspendedWorker(w, prune); - } - - const paneAlive = await isPaneAlive(w.paneId); - - if (paneAlive) { - const liveState = await getCurrentState(w.paneId); - const mapped = mapDisplayStateToRegistry(liveState); - if (mapped && mapped !== w.state) { - await registry.updateState(w.id, mapped); - } - const lastMsg = await getLastMessageTime(w); - return { entry: { worker: w, liveState, lastMsg, dead: false } }; - } - - if (prune) { - await cleanupWorkerNativeTeam(w); - await registry.unregister(w.id); - return { prunedId: w.id }; - } - - const lastMsg = await getLastMessageTime(w); - return { entry: { worker: w, liveState: '\u{1F480} dead', lastMsg, dead: true } }; -} - -async function processSuspendedWorker( - w: registry.Agent, - prune?: boolean, -): Promise<{ entry?: WorkerListEntry; prunedId?: string }> { - const SUSPEND_EXPIRY_MS = 24 * 60 * 60 * 1000; - const suspendedAge = w.suspendedAt ? Date.now() - new Date(w.suspendedAt).getTime() : Number.POSITIVE_INFINITY; - - if (prune && suspendedAge > SUSPEND_EXPIRY_MS) { - await cleanupWorkerNativeTeam(w); - if (w.role) await registry.removeTemplate(w.role).catch(() => {}); - await registry.removeTemplate(w.id).catch(() => {}); - await registry.unregister(w.id); - return { prunedId: w.id }; - } - - const lastMsg = await getLastMessageTime(w); - return { entry: { worker: w, liveState: '\u{1F4A4} suspended', lastMsg, dead: false } }; -} - -function formatWorkerRow( - id: string, - provider: string, - team: string, - role: string, - window: string, - state: string, - time: string, - lastMsg: string, - cwd: string, -): string { - return `${id.padEnd(20).substring(0, 20)}${(provider || '-').padEnd(10)}${(team || '-').padEnd(10).substring(0, 10)}${(role || '-').padEnd(14).substring(0, 14)}${(window || '-').padEnd(12).substring(0, 12)}${state.padEnd(16).substring(0, 16)}${time.padEnd(8)}${(lastMsg || '-').padEnd(10)}${cwd}`; -} - -function printWorkerRows(entries: WorkerListEntry[], stopped: StoppedEntry[]): void { - for (const { worker: w, liveState, lastMsg } of entries) { - console.log( - formatWorkerRow( - w.id, - w.provider || '-', - w.team || '-', - w.role || '-', - w.window || w.windowName || '-', - liveState, - registry.getElapsedTime(w).formatted, - lastMsg ?? '-', - w.repoPath || '-', - ), - ); - } - - for (const { template: t } of stopped) { - const lastActivity = t.lastSpawnedAt ? registry.formatElapsed(new Date(t.lastSpawnedAt)) : '-'; - console.log( - formatWorkerRow( - t.id, - t.provider || '-', - t.team || '-', - t.role || '-', - '-', - 'stopped', - '-', - lastActivity, - t.cwd || '-', - ), - ); - } -} - -function printWorkerList( - entries: WorkerListEntry[], - pruned: string[], - stopped: StoppedEntry[], - options: { json?: boolean; prune?: boolean; running?: boolean }, -): void { - if (options.json) { - printWorkerListJson(entries, pruned, stopped); - return; - } - - if (entries.length === 0 && stopped.length === 0 && pruned.length === 0) { - console.log('No agents found.'); - console.log(' Spawn one: genie agent spawn --role implementor'); - return; - } - - console.log(''); - console.log('WORKERS'); - console.log('-'.repeat(132)); - console.log(formatWorkerRow('ID', 'PROVIDER', 'TEAM', 'ROLE', 'WINDOW', 'STATE', 'TIME', 'LAST MSG', 'CWD')); - console.log('-'.repeat(132)); - - printWorkerRows(entries, stopped); - printWorkerListFooter(entries, pruned, stopped, options); -} - -function printWorkerListFooter( - entries: WorkerListEntry[], - pruned: string[], - stopped: StoppedEntry[], - options: { prune?: boolean; running?: boolean }, -): void { - if (pruned.length > 0) { - console.log(''); - console.log(`Pruned ${pruned.length} dead agent(s): ${pruned.join(', ')}`); - } - - const deadCount = entries.filter((e) => e.dead).length; - if (deadCount > 0 && !options.prune) { - console.log(`\n${deadCount} dead agent(s). Use --prune to remove.`); - } - if (stopped.length > 0 && !options.running) { - console.log(`\n${stopped.length} stopped agent(s) (templates). Use -r to hide.`); - } - console.log(''); -} - -function printWorkerListJson(entries: WorkerListEntry[], pruned: string[], stopped: StoppedEntry[]): void { - const result: Record<string, unknown>[] = entries.map(({ worker: w, liveState, lastMsg }) => ({ - id: w.id, - provider: w.provider, - transport: w.transport, - team: w.team, - role: w.role, - window: w.window || w.windowName || null, - state: liveState, - elapsed: registry.getElapsedTime(w).formatted, - lastMessage: lastMsg ?? null, - })); - for (const { template: t } of stopped) { - result.push({ - id: t.id, - provider: t.provider, - team: t.team, - role: t.role ?? null, - state: 'stopped', - lastSpawnedAt: t.lastSpawnedAt, - }); - } - if (pruned.length > 0) { - result.push(...pruned.map((id) => ({ id, state: 'dead (pruned)' }))); - } - console.log(JSON.stringify(result, null, 2)); -} - -// ============================================================================ -// Kill helpers (extracted for cognitive complexity) -// ============================================================================ - function killWorkerPane(w: registry.Agent): void { try { const { execSync } = require('node:child_process'); @@ -1052,394 +832,156 @@ function cleanupRelayFiles(id: string): void { } // ============================================================================ -// Dashboard helpers (extracted for cognitive complexity) +// Name resolution — resolve agent name to running worker // ============================================================================ -function printDashboardJson(workers: registry.Agent[]): void { - const summary = { - total: workers.length, - byProvider: { - claude: workers.filter((w) => w.provider === 'claude').length, - codex: workers.filter((w) => w.provider === 'codex').length, - }, - byState: { - spawning: workers.filter((w) => w.state === 'spawning').length, - working: workers.filter((w) => w.state === 'working').length, - idle: workers.filter((w) => w.state === 'idle').length, - done: workers.filter((w) => w.state === 'done').length, - suspended: workers.filter((w) => w.state === 'suspended').length, - }, - }; - console.log( - JSON.stringify( - { - summary, - workers: workers.map((w) => ({ - id: w.id, - provider: w.provider, - team: w.team, - role: w.role, - skill: w.skill, - state: w.state, - paneId: w.paneId, - transport: w.transport, - })), - }, - null, - 2, - ), - ); -} +/** Resolve an agent name to a running worker entry. */ +async function resolveWorkerByName(name: string): Promise<registry.Agent> { + // Try exact ID match + const exact = await registry.get(name); + if (exact) return exact; -function printDashboardText(workers: registry.Agent[], watch?: boolean): void { - console.log(''); - console.log('AGENT DASHBOARD'); - console.log('='.repeat(80)); - console.log(`Agents: ${workers.length}`); - console.log(` Claude: ${workers.filter((w) => w.provider === 'claude').length}`); - console.log(` Codex: ${workers.filter((w) => w.provider === 'codex').length}`); - console.log(''); + const workers = await registry.list(); - if (workers.length === 0) { - console.log('No active agents.'); - return; + // Try matching by role + const byRole = workers.filter((w) => w.role === name); + if (byRole.length === 1) return byRole[0]; + if (byRole.length > 1) { + console.error(`Multiple agents with role "${name}". Specify full ID:`); + for (const w of byRole) console.error(` ${w.id} (team: ${w.team})`); + process.exit(1); } - for (const w of workers) { - const elapsed = registry.getElapsedTime(w).formatted; - console.log( - ` [${w.provider || 'claude'}] ${w.id} (${w.team || 'default'}/${w.role || 'default'}) — ${w.state} — ${elapsed}`, - ); - if (w.skill) console.log(` Skill: ${w.skill}`); - console.log(` Pane: ${w.paneId} | Session: ${w.session} | Transport: ${w.transport || 'tmux'}`); + // Try matching by ID suffix (e.g., "implementor" matches "genie-implementor") + const bySuffix = workers.filter((w) => w.id.endsWith(`-${name}`)); + if (bySuffix.length === 1) return bySuffix[0]; + if (bySuffix.length > 1) { + console.error(`Multiple agents matching "${name}". Specify full ID:`); + for (const w of bySuffix) console.error(` ${w.id}`); + process.exit(1); } - console.log(''); - - if (watch) { - console.log('Watch mode: would auto-refresh every 2s (tmux required)'); - } + console.error(`Agent "${name}" not found.`); + console.error(' Run `genie ls` to see agents.'); + process.exit(1); } // ============================================================================ -// Agent Namespace (genie agent — provider-selectable orchestration) +// Exported top-level command handlers // ============================================================================ -export function registerAgentNamespace(program: Command): void { - const agent = program.command('agent').description('Agent lifecycle (spawn, list, kill, dashboard)'); - - // agent spawn - agent - .command('spawn') - .description('Spawn a new agent with provider selection') - .option('--provider <provider>', 'Provider: claude or codex', 'claude') - .option('--team <team>', 'Team name', process.env.GENIE_TEAM ?? 'genie') - .requiredOption('--role <role>', 'Worker role (e.g., implementor, tester)') - .option('--skill <skill>', 'Skill to load (optional)') - .option('--layout <layout>', 'Layout mode: mosaic (default) or vertical') - .option('--color <color>', 'Teammate pane border color') - .option('--plan-mode', 'Start teammate in plan mode') - .option('--permission-mode <mode>', 'Permission mode (e.g., acceptEdits)') - .option('--extra-args <args...>', 'Extra CLI args forwarded to provider') - .option('--cwd <path>', 'Working directory for the agent (default: current directory)') - .action( - async (options: { - provider: string; - team: string; - role?: string; - skill?: string; - layout?: string; - color?: string; - planMode?: boolean; - permissionMode?: string; - extraArgs?: string[]; - cwd?: string; - }) => { - try { - await handleWorkerSpawn(options); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }, - ); - - // agent list - agent - .command('list') - .alias('ls') - .description('List all agents (active + stopped templates)') - .option('--json', 'Output as JSON') - .option('--prune', 'Auto-remove dead agents from registry') - .option('-r, --running', 'Show only active agents (hide stopped)') - .action(async (options: { json?: boolean; prune?: boolean; running?: boolean }) => { - try { - const workers = await registry.list(); - const entries: WorkerListEntry[] = []; - const pruned: string[] = []; - - for (const w of workers) { - const result = await processWorkerForList(w, options.prune); - if (result.entry) entries.push(result.entry); - if (result.prunedId) pruned.push(result.prunedId); - } - - const stopped = options.running ? [] : await collectStoppedTemplates(entries); - - printWorkerList(entries, pruned, stopped, options); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); - - // agent kill - agent - .command('kill <id>') - .description('Force kill an agent') - .option('-y, --yes', 'Skip confirmation') - .action(async (id: string, _options: { yes?: boolean }) => { - try { - const w = await registry.get(id); - if (!w) { - console.error(`Agent "${id}" not found.`); - process.exit(1); - } - - killWorkerPane(w); - cleanupRelayFiles(id); - await cleanupWorkerNativeTeam(w); - - // Save last session ID into template before unregistering so - // ensureWorkerAlive can resume with --resume on next message. - if (w.claudeSessionId) { - const templates = await registry.listTemplates(); - const tmpl = templates.find((t) => t.id === id || t.id === w.role || t.role === w.role); - if (tmpl) { - await registry.saveTemplate({ ...tmpl, lastSessionId: w.claudeSessionId }); - } - } - - await registry.unregister(id); - - // NOTE: templates are intentionally preserved so that - // ensureWorkerAlive can auto-respawn the worker on next message. - - console.log(`Agent "${id}" killed and unregistered (template preserved).`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); - - // agent suspend - agent - .command('suspend <id>') - .description('Suspend an agent (kill pane, preserve session for resume)') - .action(async (id: string) => { - try { - const w = await registry.get(id); - if (!w) { - console.error(`Agent "${id}" not found.`); - process.exit(1); - } - if (w.state === 'suspended') { - console.log(`Agent "${id}" is already suspended.`); - return; - } - const { suspendWorker } = await import('../lib/idle-timeout.js'); - const ok = await suspendWorker(id); - if (ok) { - console.log(`Agent "${id}" suspended.`); - if (w.claudeSessionId) { - console.log(` Session preserved: ${w.claudeSessionId}`); - } - console.log(` Send a message to auto-resume: genie send ${id} "your message"`); - } else { - console.error(`Failed to suspend agent "${id}".`); - process.exit(1); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); +/** + * genie kill <name> — Force kill an agent by name. + */ +export async function handleWorkerKill(name: string): Promise<void> { + const w = await resolveWorkerByName(name); + + killWorkerPane(w); + cleanupRelayFiles(w.id); + await cleanupWorkerNativeTeam(w); + + // Save last session ID into template before unregistering so + // ensureWorkerAlive can resume with --resume on next message. + if (w.claudeSessionId) { + const templates = await registry.listTemplates(); + const tmpl = templates.find((t) => t.id === w.id || t.id === w.role || t.role === w.role); + if (tmpl) { + await registry.saveTemplate({ ...tmpl, lastSessionId: w.claudeSessionId }); + } + } - // agent watchdog - agent - .command('watchdog') - .description('Start idle timeout watchdog (suspends idle agents)') - .option('--once', 'Run a single check and exit') - .action(async (options: { once?: boolean }) => { - try { - const { checkIdleWorkers, runWatchdogLoop, getIdleTimeoutMs } = await import('../lib/idle-timeout.js'); - const timeoutMs = getIdleTimeoutMs(); + await registry.unregister(w.id); + console.log(`Agent "${w.id}" killed and unregistered (template preserved).`); +} - if (timeoutMs === 0) { - console.log('Idle timeout is disabled (GENIE_IDLE_TIMEOUT_MS=0).'); - return; - } +/** + * genie stop <name> — Stop an agent (kill pane, preserve session for resume). + */ +export async function handleWorkerStop(name: string): Promise<void> { + const w = await resolveWorkerByName(name); - console.log(`Idle timeout: ${Math.round(timeoutMs / 60000)}m`); + if (w.state === 'suspended') { + console.log(`Agent "${w.id}" is already stopped.`); + return; + } - if (options.once) { - const suspended = await checkIdleWorkers(); - if (suspended.length > 0) { - console.log(`Suspended ${suspended.length} agent(s): ${suspended.join(', ')}`); - } else { - console.log('No idle agents to suspend.'); - } - return; - } + const { suspendWorker } = await import('../lib/idle-timeout.js'); + const ok = await suspendWorker(w.id); + if (ok) { + console.log(`Agent "${w.id}" stopped.`); + if (w.claudeSessionId) { + console.log(` Session preserved: ${w.claudeSessionId}`); + } + console.log(` Send a message to auto-resume: genie send '...' --to ${w.id}`); + } else { + console.error(`Failed to stop agent "${w.id}".`); + process.exit(1); + } +} - console.log('Starting watchdog loop (Ctrl+C to stop)...'); - await runWatchdogLoop(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); +/** + * genie ls — Smart view of registered agents with runtime status. + */ +export async function handleLsCommand(options: { json?: boolean }): Promise<void> { + const dirEntries = await directory.ls(); + const workers = await registry.list(); - // agent dashboard - agent - .command('dashboard') - .description('Live status of all agents with provider metadata') - .option('--json', 'Output as JSON') - .option('-w, --watch', 'Auto-refresh every 2 seconds') - .action(async (options: { json?: boolean; watch?: boolean }) => { - try { - const workers = await registry.list(); - if (options.json) { - printDashboardJson(workers); - } else { - printDashboardText(workers, options.watch); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); + // Build status map: name → running worker info + const statusMap = new Map<string, { state: string; team: string }>(); + for (const w of workers) { + const name = w.role || w.id; + const alive = await isPaneAlive(w.paneId); + if (alive) { + statusMap.set(name, { state: w.state, team: w.team || '-' }); + } + } - // ============================================================================ - // Commands migrated from genie term - // ============================================================================ - - // agent approve — auto-approve engine management - agent - .command('approve [request-id]') - .description('Auto-approve engine management and manual approval') - .option('--status', 'Show pending/approved/denied requests') - .option('--deny <request-id>', 'Manually deny a pending request') - .option('--start', 'Start the auto-approve engine') - .option('--stop', 'Stop the auto-approve engine') - .action( - async ( - requestId: string | undefined, - options: { status?: boolean; deny?: string; start?: boolean; stop?: boolean }, - ) => { - await approveCmd.approveCommand(requestId, options); - }, - ); + type LsEntry = { name: string; dir: string; status: string; team: string; model: string }; + const entries: LsEntry[] = []; - // agent history — compressed session catch-up - agent - .command('history <worker>') - .description('Show compressed session history for an agent (catch-up)') - .option('--full', 'Show full conversation without compression') - .option('--since <n>', 'Show last N user/assistant exchanges', Number.parseInt) - .option('--json', 'Output as JSON') - .option('--raw', 'Output raw JSONL entries') - .option('--log-file <path>', 'Direct path to log file (for testing)') - .action(async (w: string, options: historyCmd.HistoryOptions) => { - await historyCmd.historyCommand(w, options); + // Add directory entries with runtime status + for (const entry of dirEntries) { + const running = statusMap.get(entry.name); + entries.push({ + name: entry.name, + dir: entry.dir || '-', + status: running ? running.state : 'offline', + team: running?.team || '-', + model: entry.model || '-', }); + statusMap.delete(entry.name); + } - // agent answer — answer worker question - agent - .command('answer <worker> <choice>') - .description('Answer a question for an agent (use "text:..." for text input)') - .action(async (w: string, choice: string) => { - await orchestrateCmd.answerQuestion(w, choice); + // Add running built-in agents not in the directory + for (const [name, info] of statusMap) { + entries.push({ + name, + dir: '(built-in)', + status: info.state, + team: info.team, + model: '-', }); + } - // agent events — stream Claude Code events - agent - .command('events [pane-id]') - .description('Stream Claude Code events from a pane or all agents') - .option('--json', 'Output events as JSON') - .option('-f, --follow', 'Continuous tailing (like tail -f)') - .option('-n, --lines <number>', 'Number of recent events to show (default: 20)', '20') - .option('--emit', 'Write events to .genie/events/<pane-id>.jsonl while tailing') - .option('--all', 'Aggregate events from all active agents') - .action( - async ( - paneId: string | undefined, - options: { json?: boolean; follow?: boolean; lines?: string; emit?: boolean; all?: boolean }, - ) => { - await eventsCmd.eventsCommand(paneId, { - json: options.json, - follow: options.follow, - lines: options.lines ? Number.parseInt(options.lines, 10) : undefined, - emit: options.emit, - all: options.all, - }); - }, - ); - - // agent close — close task and cleanup worker - agent - .command('close <task-id>') - .description('Close task/issue and cleanup agent') - .option('--no-sync', 'Skip bd sync (beads only)') - .option('--keep-worktree', "Don't remove the worktree") - .option('--merge', 'Merge worktree changes to main branch') - .option('-y, --yes', 'Skip confirmation') - .action(async (taskId: string, options: closeCmd.CloseOptions) => { - await closeCmd.closeCommand(taskId, options); - }); + if (options.json) { + console.log(JSON.stringify(entries, null, 2)); + return; + } - // agent ship — mark done, merge, cleanup - agent - .command('ship <task-id>') - .description('Mark task as done and cleanup agent') - .option('--keep-worktree', "Don't remove the worktree") - .option('--merge', 'Merge worktree changes to main branch') - .option('-y, --yes', 'Skip confirmation') - .action(async (taskId: string, options: shipCmd.ShipOptions) => { - await shipCmd.shipCommand(taskId, options); - }); + if (entries.length === 0) { + console.log('No agents registered. Use `genie dir add <name> --dir <path>` to register one.'); + return; + } - // agent read — read worker pane output - agent - .command('read <target>') - .description('Read terminal output from an agent pane') - .option('-n, --lines <number>', 'Number of lines to read') - .option('--from <line>', 'Start line') - .option('--to <line>', 'End line') - .option('--range <range>', 'Line range (e.g., "10-20")') - .option('--search <text>', 'Search for text') - .option('--grep <pattern>', 'Grep for pattern') - .option('-f, --follow', 'Follow mode (like tail -f)') - .option('--all', 'Show all output') - .option('-r, --reverse', 'Reverse order') - .option('--json', 'Output as JSON') - .action(async (target: string, options: readCmd.ReadOptions) => { - await readCmd.readSessionLogs(target, options); - }); + console.log(''); + console.log(formatLsRow('NAME', 'DIR', 'STATUS', 'TEAM', 'MODEL')); + console.log('-'.repeat(94)); + for (const e of entries) { + console.log(formatLsRow(e.name, e.dir, e.status, e.team, e.model)); + } + console.log(''); +} - // agent exec — execute command in worker pane - agent - .command('exec <target> <command>') - .description('Execute command in an agent pane') - .option('-q, --quiet', 'Suppress output') - .option('-t, --timeout <ms>', 'Timeout in milliseconds') - .action(async (target: string, command: string, options: execCmd.ExecOptions) => { - await execCmd.executeInSession(target, command, options); - }); +function formatLsRow(name: string, dir: string, status: string, team: string, model: string): string { + return `${name.padEnd(20).substring(0, 20)}${dir.padEnd(40).substring(0, 40)}${status.padEnd(12).substring(0, 12)}${team.padEnd(12).substring(0, 12)}${model}`; } diff --git a/src/term-commands/approve.test.ts b/src/term-commands/approve.test.ts index 77dce8f2e..48936e7a2 100644 --- a/src/term-commands/approve.test.ts +++ b/src/term-commands/approve.test.ts @@ -240,17 +240,3 @@ describe('engine lifecycle', () => { expect(() => stopEngine()).not.toThrow(); }); }); - -// ============================================================================ -// Tests: WorkOptions --no-auto-approve flag -// ============================================================================ - -describe('WorkOptions includes noAutoApprove', () => { - it('WorkOptions type accepts noAutoApprove field', async () => { - // Import the type and verify the field compiles - const { workCommand } = await import('./work.js'); - // This test is compile-time: if WorkOptions does not include noAutoApprove, - // TypeScript compilation will fail. At runtime we just verify the import succeeds. - expect(typeof workCommand).toBe('function'); - }); -}); diff --git a/src/term-commands/approve.ts b/src/term-commands/approve.ts index 56056d00c..f1fea2d33 100644 --- a/src/term-commands/approve.ts +++ b/src/term-commands/approve.ts @@ -18,7 +18,7 @@ import { sendApprovalViaTmux, } from '../lib/auto-approve-engine.js'; import { loadAutoApproveConfig } from '../lib/auto-approve.js'; -import { type PermissionRequestQueue, createPermissionRequestQueue } from '../lib/event-listener.js'; +import type { PermissionRequestQueue } from '../lib/event-listener.js'; // ============================================================================ // Types @@ -75,7 +75,6 @@ interface StartEngineOptions { // ============================================================================ let currentEngine: AutoApproveEngine | null = null; -const sharedQueue: PermissionRequestQueue = createPermissionRequestQueue(); // ============================================================================ // Status @@ -252,104 +251,3 @@ export function stopEngine(): void { } // ============================================================================ -// CLI Command Handler -// ============================================================================ - -interface ApproveCommandOptions { - status?: boolean; - deny?: string; - start?: boolean; - stop?: boolean; -} - -/** - * Display status of all auto-approve requests. - */ -function displayStatus(auditDir: string): void { - const entries = getStatusEntries({ auditDir, queue: sharedQueue }); - if (entries.length === 0) { - console.log('No auto-approve requests found.'); - return; - } - - console.log('Auto-Approve Requests:'); - console.log(''); - for (const entry of entries) { - const statusLabel = entry.status.toUpperCase().padEnd(10); - const pane = entry.paneId ?? 'N/A'; - console.log(` [${statusLabel}] ${entry.requestId} ${entry.toolName} pane:${pane} ${entry.timestamp}`); - if (entry.reason) { - console.log(` Reason: ${entry.reason}`); - } - } -} - -/** - * Handle manual approve or deny of a request. - */ -function handleManualAction(id: string, action: 'approve' | 'deny'): void { - const fn = action === 'approve' ? manualApprove : manualDeny; - const result = fn(id, { queue: sharedQueue }); - if (result) { - console.log(`${action === 'approve' ? 'Approved' : 'Denied'} request: ${id}`); - } else { - console.error(`Request "${id}" not found in pending queue.`); - process.exit(1); - } -} - -/** - * Main CLI command handler for `genie agent approve`. - * - * Dispatches to the appropriate sub-function based on options: - * - --status: show pending/approved/denied requests - * - --start: start the auto-approve engine - * - --stop: stop the auto-approve engine - * - --deny <id>: manually deny a pending request - * - <request-id> (argument): manually approve a pending request - */ -export async function approveCommand(requestId: string | undefined, options: ApproveCommandOptions): Promise<void> { - const repoPath = process.cwd(); - const auditDir = repoPath; - - if (options.status) { - displayStatus(auditDir); - return; - } - - if (options.start) { - if (isEngineRunning()) { - console.log('Auto-approve engine is already running.'); - return; - } - await startEngine({ auditDir, repoPath }); - console.log('Auto-approve engine started.'); - return; - } - - if (options.stop) { - if (!isEngineRunning()) { - console.log('Auto-approve engine is not running.'); - return; - } - stopEngine(); - console.log('Auto-approve engine stopped.'); - return; - } - - if (options.deny) { - handleManualAction(options.deny, 'deny'); - return; - } - if (requestId) { - handleManualAction(requestId, 'approve'); - return; - } - - console.log('Usage:'); - console.log(' genie agent approve --status Show pending/approved/denied requests'); - console.log(' genie agent approve <request-id> Manually approve a pending request'); - console.log(' genie agent approve --deny <request-id> Manually deny a pending request'); - console.log(' genie agent approve --start Start the auto-approve engine'); - console.log(' genie agent approve --stop Stop the auto-approve engine'); -} diff --git a/src/term-commands/beads-validate.ts b/src/term-commands/beads-validate.ts deleted file mode 100644 index ddc1fa547..000000000 --- a/src/term-commands/beads-validate.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * beads-validate command - * - * Scriptable validation for .beads/issues.jsonl existence + JSONL parse. - * Minimal by design. - */ - -import { readFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; - -export interface BeadsValidateOptions { - repo?: string; - json?: boolean; -} - -function validateJsonlLine( - line: string, - lineNum: number, - ids: Set<string>, - errors: Array<{ line: number; error: string }>, -): boolean { - if (!line.trim()) return false; - try { - const obj = JSON.parse(line); - if (!obj?.id) { - errors.push({ line: lineNum, error: 'missing id' }); - } else if (ids.has(obj.id)) { - errors.push({ line: lineNum, error: `duplicate id: ${obj.id}` }); - } else { - ids.add(obj.id); - } - return true; - } catch (e) { - errors.push({ line: lineNum, error: e instanceof Error ? e.message : 'invalid json' }); - return true; - } -} - -function printValidationResult( - ok: boolean, - issuesPath: string, - count: number, - errors: Array<{ line: number; error: string }>, -): void { - if (ok) { - console.log(`✅ Beads issues.jsonl valid (${count} records) at ${issuesPath}`); - return; - } - console.error(`❌ Beads issues.jsonl invalid at ${issuesPath}`); - for (const e of errors.slice(0, 20)) { - console.error(` line ${e.line}: ${e.error}`); - } - if (errors.length > 20) { - console.error(` ...and ${errors.length - 20} more`); - } -} - -export async function beadsValidateCommand(options: BeadsValidateOptions = {}): Promise<void> { - const repoPath = resolve(options.repo || process.cwd()); - const issuesPath = join(repoPath, '.beads', 'issues.jsonl'); - - let content: string; - try { - content = await readFile(issuesPath, 'utf-8'); - } catch { - const msg = `❌ Missing: ${issuesPath}`; - if (options.json) { - console.log(JSON.stringify({ ok: false, error: msg, issuesPath }, null, 2)); - } else { - console.error(msg); - } - process.exit(1); - } - - const errors: Array<{ line: number; error: string }> = []; - const ids = new Set<string>(); - let count = 0; - - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (validateJsonlLine(lines[i], i + 1, ids, errors)) count++; - } - - const ok = errors.length === 0; - - if (options.json) { - console.log(JSON.stringify({ ok, issuesPath, count, errors }, null, 2)); - } else { - printValidationResult(ok, issuesPath, count, errors); - } - - if (!ok) process.exit(1); -} diff --git a/src/term-commands/brainstorm-beads.ts b/src/term-commands/brainstorm-beads.ts deleted file mode 100644 index 6b7575175..000000000 --- a/src/term-commands/brainstorm-beads.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Brainstorm completion helpers - * - * When /brainstorm reaches WRS=100 and writes: - * .genie/brainstorms/<slug>/design.md - * we also upsert a Beads issue record into: - * <repo>/.beads/issues.jsonl - */ - -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; -import { - type BeadsIssueRecord, - ensureBeadsIssuesPath, - getSourceRepo, - upsertBeadsIssueJsonl, -} from '../lib/beads-issues-jsonl.js'; - -interface CrystallizeBrainstormOptions { - repoPath: string; - slug: string; // kebab-case - title: string; - designMarkdown: string; - status?: 'open' | 'closed'; - dependsOn?: string[]; -} - -export async function crystallizeBrainstormAndUpsertBeads( - opts: CrystallizeBrainstormOptions, -): Promise<{ designPath: string; beadsPath: string; record: BeadsIssueRecord }> { - const repoPath = resolve(opts.repoPath); - const slug = opts.slug; - const title = opts.title; - const status = opts.status ?? 'open'; - - const depends_on = opts.dependsOn && opts.dependsOn.length > 0 ? opts.dependsOn : ['hq-roadmap']; - - const designPath = join(repoPath, '.genie', 'brainstorms', slug, 'design.md'); - await mkdir(dirname(designPath), { recursive: true }); - await writeFile(designPath, opts.designMarkdown, 'utf-8'); - - const issuesPath = await ensureBeadsIssuesPath(repoPath); - const source_repo = await getSourceRepo(repoPath); - - const record = await upsertBeadsIssueJsonl(issuesPath, { - id: slug, - title, - status, - source_repo, - wish_slug: slug, - depends_on, - }); - - return { - designPath, - beadsPath: issuesPath, - record, - }; -} diff --git a/src/term-commands/close.ts b/src/term-commands/close.ts deleted file mode 100644 index cb9665934..000000000 --- a/src/term-commands/close.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Close command - Close task/issue and cleanup worker - * - * Supports both local wishes (.genie/tasks.json) and beads issues. - * Backend is auto-detected based on whether .genie/ directory exists. - * - * Usage: - * genie agent close <task-id> - Close task, cleanup worktree, kill agent - * - * Options: - * --no-sync - Skip bd sync (beads only, no-op for local) - * --keep-worktree - Don't remove the worktree - * --merge - Merge worktree changes to main branch - * -y, --yes - Skip confirmation - */ - -import { join } from 'node:path'; -import { confirm } from '@inquirer/prompts'; -import { $ } from 'bun'; -import * as registry from '../lib/agent-registry.js'; -import * as beadsRegistry from '../lib/beads-registry.js'; -import { type TaskBackend, getBackend } from '../lib/task-backend.js'; -import * as tmux from '../lib/tmux.js'; -import { cleanupEventFile } from './events.js'; - -// Use beads registry only when enabled AND bd exists on PATH -const useBeadsRegistry = - beadsRegistry.isBeadsRegistryEnabled() && - (() => { - const BunExt = Bun as unknown as { which?: (name: string) => string | null }; - return typeof BunExt.which === 'function' ? Boolean(BunExt.which('bd')) : true; - })(); - -// ============================================================================ -// Types -// ============================================================================ - -export interface CloseOptions { - noSync?: boolean; - keepWorktree?: boolean; - merge?: boolean; - yes?: boolean; -} - -// ============================================================================ -// Configuration -// ============================================================================ - -// Worktrees are created inside the project at .genie/worktrees/<taskId> -const WORKTREE_DIR_NAME = '.genie/worktrees'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Run bd command - */ -async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> { - try { - const result = await $`bd ${args}`.quiet(); - return { stdout: result.stdout.toString().trim(), exitCode: 0 }; - } catch (error) { - const shellErr = error as { stdout?: Buffer; exitCode?: number }; - return { stdout: shellErr.stdout?.toString().trim() || '', exitCode: shellErr.exitCode || 1 }; - } -} - -/** - * Close beads issue via `bd close` - */ -async function closeBeadsIssue(taskId: string): Promise<boolean> { - const { exitCode } = await runBd(['close', taskId]); - return exitCode === 0; -} - -/** - * Close local wish by marking it as done - */ -async function closeLocalTask(backend: TaskBackend, taskId: string): Promise<boolean> { - return backend.markDone(taskId); -} - -/** - * Sync beads to git - */ -async function syncBeads(): Promise<boolean> { - const { exitCode } = await runBd(['sync']); - return exitCode === 0; -} - -/** - * Merge worktree branch to main - */ -async function mergeToMain(repoPath: string, branchName: string): Promise<boolean> { - try { - // Get current branch - const currentResult = await $`git -C ${repoPath} branch --show-current`.quiet(); - const currentBranch = currentResult.stdout.toString().trim(); - - if (currentBranch === branchName) { - console.log(`⚠️ Already on branch ${branchName}. Skipping merge.`); - return true; - } - - // Checkout main and merge - console.log(` Switching to ${currentBranch}...`); - await $`git -C ${repoPath} checkout ${currentBranch}`.quiet(); - - console.log(` Merging ${branchName}...`); - await $`git -C ${repoPath} merge ${branchName} --no-edit`.quiet(); - - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`⚠️ Merge failed: ${message}`); - return false; - } -} - -/** - * Remove worktree - * Checks .genie/worktrees first, then bd worktree - */ -async function removeWorktree(taskId: string, repoPath: string): Promise<boolean> { - // First, check .genie/worktrees location - const inProjectWorktree = join(repoPath, WORKTREE_DIR_NAME, taskId); - try { - await $`git -C ${repoPath} worktree remove ${inProjectWorktree} --force`.quiet(); - return true; - } catch { - // Worktree may not exist at this location, continue checking - } - - // Try bd worktree when beads registry is enabled - if (useBeadsRegistry) { - try { - const removed = await beadsRegistry.removeWorktree(taskId); - if (removed) return true; - } catch { - // Fall through - } - } - - return true; // Already doesn't exist -} - -/** - * Kill worker pane - */ -async function killWorkerPane(paneId: string): Promise<boolean> { - try { - await tmux.killPane(paneId); - return true; - } catch { - return false; // Pane may already be gone - } -} - -// ============================================================================ -// Worker Cleanup Helpers -// ============================================================================ - -/** - * Kill a single worker's window or pane via tmux. - */ -async function killWorkerTmux(w: registry.Agent): Promise<void> { - if (w.windowId && w.session) { - console.log(`💀 Killing agent window "${w.windowName || w.windowId}" (${w.id})...`); - try { - const sessionObj = await tmux.findSessionByName(w.session); - if (sessionObj) { - try { - await tmux.killWindowQualified(sessionObj.id, w.windowId); - } catch { - await tmux.killWindow(w.windowId); - } - } else { - await tmux.killWindow(w.windowId); - } - console.log(' ✅ Window killed'); - } catch { - console.log(' ℹ️ Window already gone'); - } - return; - } - if (w.windowName) { - console.log(`💀 Killing agent window "${w.windowName}" (${w.id})...`); - try { - await tmux.killWindow(w.windowName); - console.log(' ✅ Window killed'); - } catch { - console.log(' ℹ️ Window already gone'); - } - return; - } - console.log(`💀 Killing agent pane (${w.id})...`); - await killWorkerPane(w.paneId); - console.log(' ✅ Pane killed'); -} - -/** - * Unregister a worker from all registries and clean up event files. - */ -async function unregisterWorker(w: registry.Agent): Promise<void> { - if (useBeadsRegistry) { - try { - await beadsRegistry.unbindWork(w.id); - await beadsRegistry.setAgentState(w.id, 'done'); - await beadsRegistry.deleteAgent(w.id); - } catch { - /* Non-fatal */ - } - } - await registry.unregister(w.id); - await cleanupEventFile(w.paneId).catch(() => {}); -} - -/** - * Close a task using the appropriate backend. - */ -async function closeTaskByBackend(backend: TaskBackend, taskId: string, isLocal: boolean): Promise<void> { - console.log(`📝 Closing ${taskId}...`); - if (isLocal) { - const closed = await closeLocalTask(backend, taskId); - console.log(closed ? ' ✅ Task marked as done' : `❌ Failed to close ${taskId}. Check .genie/tasks.json.`); - } else { - const closed = await closeBeadsIssue(taskId); - console.log(closed ? ' ✅ Issue closed' : `❌ Failed to close ${taskId}. Check \`bd show ${taskId}\`.`); - } -} - -// ============================================================================ -// Main Command -// ============================================================================ - -/** - * Find representative worker for a task. - */ -async function findRepresentativeWorker(allWorkers: registry.Agent[], taskId: string): Promise<registry.Agent | null> { - if (useBeadsRegistry) { - const w = await beadsRegistry.findByTask(taskId); - if (w) return w; - } - return allWorkers.length > 0 ? allWorkers[0] : null; -} - -/** - * Prompt user to confirm closing a task. - */ -async function confirmClose(taskId: string, workerCount: number, worker: registry.Agent | null): Promise<boolean> { - const workerMsg = - workerCount > 1 ? ` and kill ${workerCount} agents` : worker ? ` and kill agent (pane ${worker.paneId})` : ''; - return confirm({ message: `Close ${taskId}${workerMsg}?`, default: true }); -} - -/** - * Handle worktree cleanup (merge + remove). - */ -async function handleWorktreeCleanup(worker: registry.Agent, taskId: string, options: CloseOptions): Promise<void> { - if (!worker.worktree || options.keepWorktree) return; - if (options.merge) { - console.log('🔀 Merging changes...'); - const merged = await mergeToMain(worker.repoPath, taskId); - if (merged) console.log(' ✅ Merged to main'); - } - console.log('🌳 Removing worktree...'); - const removed = await removeWorktree(taskId, worker.repoPath); - if (removed) console.log(' ✅ Worktree removed'); -} - -/** - * Sync beads to git if applicable. - */ -async function maybeSyncBeads(isLocal: boolean, noSync: boolean | undefined): Promise<void> { - if (isLocal || noSync) return; - console.log('🔄 Syncing beads...'); - console.log((await syncBeads()) ? ' ✅ Synced to git' : ' ⚠️ Sync failed (non-fatal)'); -} - -/** - * Kill and unregister all workers for a task. - */ -async function killAndUnregisterAll(allWorkers: registry.Agent[]): Promise<void> { - for (const w of allWorkers) { - await killWorkerTmux(w); - await unregisterWorker(w); - } - if (allWorkers.length > 0) console.log(` ✅ ${allWorkers.length} agent(s) unregistered`); -} - -export async function closeCommand(taskId: string, options: CloseOptions = {}): Promise<void> { - try { - const repoPath = process.cwd(); - const backend = getBackend(repoPath); - const isLocal = backend.kind === 'local'; - - const allWorkers = await registry.findAllByTask(taskId); - const worker = await findRepresentativeWorker(allWorkers, taskId); - - if (allWorkers.length === 0) { - console.log(`ℹ️ No active agent for ${taskId}. Closing ${isLocal ? 'task' : 'issue'} only.`); - } else if (allWorkers.length > 1) { - console.log(`📌 Found ${allWorkers.length} agents for ${taskId}`); - } - - if (!options.yes) { - if (!(await confirmClose(taskId, allWorkers.length, worker))) { - console.log('Cancelled.'); - return; - } - } - - await closeTaskByBackend(backend, taskId, isLocal); - await maybeSyncBeads(isLocal, options.noSync); - if (worker) await handleWorktreeCleanup(worker, taskId, options); - await killAndUnregisterAll(allWorkers); - - console.log(`\n✅ ${taskId} closed successfully`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} diff --git a/src/term-commands/council.ts b/src/term-commands/council.ts deleted file mode 100644 index 1e3230bfb..000000000 --- a/src/term-commands/council.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Council command - Spawn dual Claude instances for multi-model deliberation - * - * Usage: - * genie council - Use default preset - * genie council --preset <name> - Use specific preset - * genie council --skill <skill> - Override skill (default: council) - * - * Options: - * -s, --session <name> - Target tmux session - * --preset <name> - Council preset to use - * --skill <skill> - Skill to load on both instances - * --no-focus - Don't focus the new window - * - * Creates a tmux window with two vertical panes, each running Claude - * with a different model profile for diverse pair programming. - */ - -import { - getCouncilPreset, - getDefaultCouncilPreset, - getFallbackCouncilPreset, - getSessionName, - getWorkerProfile, - loadGenieConfig, -} from '../lib/genie-config.js'; -import * as skillLoader from '../lib/skill-loader.js'; -import { buildSpawnCommand } from '../lib/spawn-command.js'; -import * as tmux from '../lib/tmux.js'; -import type { CouncilPreset } from '../types/genie-config.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface CouncilOptions { - session?: string; - preset?: string; - skill?: string; - focus?: boolean; -} - -// ============================================================================ -// Council Command -// ============================================================================ - -/** - * Main council command - spawn dual Claude instances - */ -function resolvePreset( - config: ReturnType<typeof loadGenieConfig> extends Promise<infer T> ? T : never, - presetName?: string, -): CouncilPreset { - if (!presetName) { - return getDefaultCouncilPreset(config) || getFallbackCouncilPreset(config); - } - - const found = getCouncilPreset(config, presetName); - if (found) return found; - - const available = Object.keys(config.councilPresets || {}); - console.error(`\x1b[31m✗ Council preset '${presetName}' not found.\x1b[0m`); - console.log( - available.length > 0 - ? `Available presets: ${available.join(', ')}` - : 'No presets configured. Add councilPresets to ~/.genie/config.json', - ); - process.exit(1); -} - -export async function councilCommand(options: CouncilOptions = {}): Promise<void> { - const config = await loadGenieConfig(); - const session = options.session || getSessionName(); - const preset = resolvePreset(config, options.preset); - - // Allow skill override via flag - const skillName = options.skill || preset.skill; - - // 3. Validate both worker profiles exist - const leftProfile = getWorkerProfile(config, preset.left); - const rightProfile = getWorkerProfile(config, preset.right); - - if (!leftProfile) { - console.error(`\x1b[31m✗ Left profile '${preset.left}' not found.\x1b[0m`); - console.log(`Run: genie profiles add ${preset.left}`); - process.exit(1); - } - - if (!rightProfile) { - console.error(`\x1b[31m✗ Right profile '${preset.right}' not found.\x1b[0m`); - console.log(`Run: genie profiles add ${preset.right}`); - process.exit(1); - } - - // 4. Check if skill exists (optional - will skip warmup if not found) - let skill: skillLoader.SkillInfo | null = null; - if (skillName) { - skill = await skillLoader.findSkill(skillName); - if (!skill) { - console.log(`\x1b[33m⚠ Skill '${skillName}' not found - spawning without warmup\x1b[0m`); - } - } - - // 5. Ensure session exists - const sessions = await tmux.listSessions(); - const sessionExists = sessions.some((s) => s.name === session); - if (!sessionExists) { - console.log(`Creating tmux session: ${session}`); - await tmux.executeTmux(`new-session -d -s '${session}'`); - } - - // 6. Find unique window name - const windows = await tmux.listWindows(session); - const windowNames = windows.map((w) => w.name); - let windowName = 'council'; - let counter = 1; - while (windowNames.includes(windowName)) { - windowName = `council-${counter}`; - counter++; - } - - // 7. Create new window with left pane - console.log(`\nCreating council window: ${windowName}`); - await tmux.executeTmux(`new-window -t '${session}' -n '${windowName}'`); - - const leftPaneId = `${session}:${windowName}.0`; - - // 8. Split horizontally for right pane (50/50) - await tmux.executeTmux(`split-window -h -t '${leftPaneId}'`); - const rightPaneId = `${session}:${windowName}.1`; - - // 9. Get working directory - const cwd = process.cwd(); - const escapedCwd = cwd.replace(/'/g, "'\\''"); - - // 10. Build spawn commands - const leftCmd = buildSpawnCommand(leftProfile, {}); - const rightCmd = buildSpawnCommand(rightProfile, {}); - - // 11. Start Claude in both panes - console.log(` Left pane: ${preset.left}`); - console.log(` Right pane: ${preset.right}`); - if (skill) { - console.log(` Skill: ${skillName}`); - } - - await tmux.executeCommand(leftPaneId, `cd '${escapedCwd}' && ${leftCmd}`, true, false); - await tmux.executeCommand(rightPaneId, `cd '${escapedCwd}' && ${rightCmd}`, true, false); - - // 12. Wait for Claude instances to start, then send skill if available - if (skill) { - await new Promise((resolve) => setTimeout(resolve, 2500)); - - // 13. Send skill to both panes - const slashCommand = `/${skillName}`; - const escapedSlash = slashCommand.replace(/'/g, "'\\''"); - - await tmux.executeTmux(`send-keys -t '${leftPaneId}' '${escapedSlash}' Enter`); - await tmux.executeTmux(`send-keys -t '${rightPaneId}' '${escapedSlash}' Enter`); - } - - // 14. Focus left pane (unless disabled) - if (options.focus !== false) { - await tmux.executeTmux(`select-pane -t '${leftPaneId}'`); - } - - // 15. Summary - console.log('\n\x1b[32m✓ Council ready\x1b[0m'); - console.log(`\n Window: ${session}:${windowName}`); - console.log(' Switch panes: Ctrl-B + arrow keys'); - console.log(` Attach: tmux attach -t ${session}:${windowName}`); - console.log(''); -} diff --git a/src/term-commands/create.ts b/src/term-commands/create.ts deleted file mode 100644 index 5abdbfbb1..000000000 --- a/src/term-commands/create.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Create command - Create a task. - * - * - In repos with beads (bd), creates a beads issue (bd-123) - * - In repos with a tracked .genie/ (macro repo like blanco), creates a local wish task (wish-42) - */ - -import { getBackend } from '../lib/task-backend.js'; -import { linkTask, wishExists } from '../lib/wish-tasks.js'; - -export interface CreateOptions { - description?: string; - parent?: string; - wish?: string; - json?: boolean; -} - -async function handleWishLink(repoPath: string, wish: string, taskId: string, title: string): Promise<void> { - if (await wishExists(repoPath, wish)) { - await linkTask(repoPath, wish, taskId, title); - console.log(`✅ Created ${taskId} and linked to wish "${wish}"`); - } else { - console.log(`✅ Created ${taskId}`); - console.warn(`⚠️ Wish "${wish}" not found - task not linked`); - } -} - -export async function createCommand(title: string, options: CreateOptions = {}): Promise<void> { - const repoPath = process.cwd(); - const backend = getBackend(repoPath); - - try { - const task = await backend.create(title, { - description: options.description, - parent: options.parent, - }); - - if (options.wish) { - await handleWishLink(repoPath, options.wish, task.id, title); - } else if (options.json) { - const full = await backend.get(task.id); - console.log(JSON.stringify(full || task, null, 2)); - return; - } else { - console.log(`Created: ${task.id} - "${task.title}" (${backend.kind})`); - } - - if (options.parent) console.log(` Blocked by: ${options.parent}`); - - if (!options.json) { - console.log('\nNext steps:'); - console.log(` genie work ${task.id} - Start working on it`); - console.log( - backend.kind === 'beads' - ? ` bd show ${task.id} - View details` - : ' (Local tasks live in .genie/tasks.json)', - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Failed to create task: ${message || String(error)}`); - process.exit(1); - } -} diff --git a/src/term-commands/daemon.ts b/src/term-commands/daemon.ts deleted file mode 100644 index 51678a572..000000000 --- a/src/term-commands/daemon.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Daemon command - Manage beads daemon - * - * Usage: - * genie daemon start - Start beads daemon (auto-commit, auto-sync) - * genie daemon stop - Stop beads daemon - * genie daemon status - Show daemon status - * genie daemon restart - Restart daemon - * - * Options: - * --auto-commit - Enable auto-commit (default: true for start) - * --auto-push - Enable auto-push to remote - * --json - Output as JSON - */ - -import * as beadsRegistry from '../lib/beads-registry.js'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface DaemonStartOptions { - autoCommit?: boolean; - autoPush?: boolean; -} - -export interface DaemonStatusOptions { - json?: boolean; -} - -// ============================================================================ -// Commands -// ============================================================================ - -/** - * Start the beads daemon - */ -export async function startCommand(options: DaemonStartOptions = {}): Promise<void> { - try { - // Check if already running - const status = await beadsRegistry.checkDaemonStatus(); - if (status.running) { - console.log('ℹ️ Daemon is already running'); - if (status.pid) { - console.log(` PID: ${status.pid}`); - } - return; - } - - console.log('🚀 Starting beads daemon...'); - const started = await beadsRegistry.startDaemon({ - autoCommit: options.autoCommit !== false, // Default to true - autoPush: options.autoPush, - }); - - if (started) { - console.log(' ✅ Daemon started'); - - // Show updated status - const newStatus = await beadsRegistry.checkDaemonStatus(); - if (newStatus.pid) { - console.log(` PID: ${newStatus.pid}`); - } - if (newStatus.autoCommit) { - console.log(' Auto-commit: enabled'); - } - if (newStatus.autoPush) { - console.log(' Auto-push: enabled'); - } - } else { - console.error('❌ Failed to start daemon'); - console.log(' Check `bd daemon status` for details'); - process.exit(1); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} - -/** - * Stop the beads daemon - */ -export async function stopCommand(): Promise<void> { - try { - // Check if running - const status = await beadsRegistry.checkDaemonStatus(); - if (!status.running) { - console.log('ℹ️ Daemon is not running'); - return; - } - - console.log('🛑 Stopping beads daemon...'); - const stopped = await beadsRegistry.stopDaemon(); - - if (stopped) { - console.log(' ✅ Daemon stopped'); - } else { - console.error('❌ Failed to stop daemon'); - process.exit(1); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} - -/** - * Show daemon status - */ -function printDaemonStatus(status: Awaited<ReturnType<typeof beadsRegistry.checkDaemonStatus>>): void { - console.log('Beads Daemon Status'); - console.log('───────────────────'); - console.log(`Running: ${status.running ? '✅ yes' : '❌ no'}`); - - if (!status.running) { - console.log('\nRun `genie daemon start` to start the daemon'); - return; - } - - if (status.pid) console.log(`PID: ${status.pid}`); - if (status.lastSync) console.log(`Last sync: ${status.lastSync}`); - if (status.autoCommit !== undefined) console.log(`Auto-commit: ${status.autoCommit ? 'enabled' : 'disabled'}`); - if (status.autoPush !== undefined) console.log(`Auto-push: ${status.autoPush ? 'enabled' : 'disabled'}`); -} - -export async function statusCommand(options: DaemonStatusOptions = {}): Promise<void> { - try { - const status = await beadsRegistry.checkDaemonStatus(); - - if (options.json) { - console.log(JSON.stringify(status, null, 2)); - return; - } - - printDaemonStatus(status); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} - -/** - * Restart the beads daemon - */ -export async function restartCommand(options: DaemonStartOptions = {}): Promise<void> { - try { - // Check if running and stop - const status = await beadsRegistry.checkDaemonStatus(); - if (status.running) { - console.log('🛑 Stopping beads daemon...'); - await beadsRegistry.stopDaemon(); - console.log(' ✅ Stopped'); - } - - // Start with new options - console.log('🚀 Starting beads daemon...'); - const started = await beadsRegistry.startDaemon({ - autoCommit: options.autoCommit !== false, - autoPush: options.autoPush, - }); - - if (started) { - console.log(' ✅ Daemon restarted'); - } else { - console.error('❌ Failed to restart daemon'); - process.exit(1); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} diff --git a/src/term-commands/dir.ts b/src/term-commands/dir.ts new file mode 100644 index 000000000..12ffce486 --- /dev/null +++ b/src/term-commands/dir.ts @@ -0,0 +1,268 @@ +/** + * Dir Namespace — Agent directory CRUD commands. + * + * Commands: + * genie dir add <name> — Register an agent + * genie dir rm <name> — Remove an agent + * genie dir ls [<name>] — List all or show single entry + * genie dir edit <name> — Update entry fields + */ + +import { resolve as resolvePath } from 'node:path'; +import type { Command } from 'commander'; +import * as directory from '../lib/agent-directory.js'; +import { ALL_BUILTINS } from '../lib/builtin-agents.js'; +import { contractPath } from '../lib/genie-config.js'; + +export function registerDirNamespace(program: Command): void { + const dir = program.command('dir').description('Agent directory management'); + + // dir add <name> + dir + .command('add <name>') + .description('Register an agent in the directory') + .requiredOption('--dir <path>', 'Agent folder (CWD + AGENTS.md)') + .option('--repo <path>', 'Default git repo (overridden by team)') + .option('--prompt-mode <mode>', 'Prompt mode: append or system', 'append') + .option('--model <model>', 'Default model (sonnet, opus, codex)') + .option('--roles <roles...>', 'Built-in roles this agent can orchestrate') + .action( + async ( + name: string, + options: { + dir: string; + repo?: string; + promptMode: string; + model?: string; + roles?: string[]; + }, + ) => { + try { + const promptMode = validatePromptMode(options.promptMode); + const entry = await directory.add({ + name, + dir: resolvePath(options.dir), + repo: options.repo ? resolvePath(options.repo) : undefined, + promptMode, + model: options.model, + roles: options.roles, + }); + console.log(`Agent "${entry.name}" registered.`); + console.log(` Dir: ${contractPath(entry.dir)}`); + if (entry.repo) console.log(` Repo: ${contractPath(entry.repo)}`); + console.log(` Prompt mode: ${entry.promptMode}`); + if (entry.model) console.log(` Model: ${entry.model}`); + if (entry.roles?.length) console.log(` Roles: ${entry.roles.join(', ')}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }, + ); + + // dir rm <name> + dir + .command('rm <name>') + .description('Remove an agent from the directory') + .action(async (name: string) => { + try { + const removed = await directory.rm(name); + if (removed) { + console.log(`Agent "${name}" removed from directory.`); + } else { + console.error(`Agent "${name}" not found in directory.`); + process.exit(1); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); + + // dir ls [<name>] + dir + .command('ls [name]') + .description('List all agents or show single entry details') + .option('--json', 'Output as JSON') + .option('--builtins', 'Include built-in roles and council members') + .action(async (name: string | undefined, options: { json?: boolean; builtins?: boolean }) => { + try { + if (name) { + // Show single entry + await showEntry(name, options.json); + } else { + // List all + await listEntries(options.json, options.builtins); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); + + // dir edit <name> + dir + .command('edit <name>') + .description('Update an agent directory entry') + .option('--dir <path>', 'Agent folder (CWD + AGENTS.md)') + .option('--repo <path>', 'Default git repo') + .option('--prompt-mode <mode>', 'Prompt mode: append or system') + .option('--model <model>', 'Default model') + .option('--roles <roles...>', 'Built-in roles this agent can orchestrate') + .action(async (name: string, options: EditOptions) => { + try { + await handleEdit(name, options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); +} + +interface EditOptions { + dir?: string; + repo?: string; + promptMode?: string; + model?: string; + roles?: string[]; +} + +async function handleEdit(name: string, options: EditOptions): Promise<void> { + const updates: Parameters<typeof directory.edit>[1] = {}; + if (options.dir) updates.dir = resolvePath(options.dir); + if (options.repo) updates.repo = resolvePath(options.repo); + if (options.promptMode) updates.promptMode = validatePromptMode(options.promptMode); + if (options.model) updates.model = options.model; + if (options.roles) updates.roles = options.roles; + + if (Object.keys(updates).length === 0) { + console.error('No fields to update. Provide at least one of: --dir, --repo, --prompt-mode, --model, --roles'); + process.exit(1); + } + + const entry = await directory.edit(name, updates); + console.log(`Agent "${name}" updated.`); + printEntry(entry); +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function validatePromptMode(mode: string): 'system' | 'append' { + if (mode !== 'system' && mode !== 'append') { + throw new Error(`Invalid prompt mode "${mode}". Must be "append" or "system".`); + } + return mode; +} + +function printEntry(entry: directory.DirectoryEntry): void { + console.log(` Name: ${entry.name}`); + console.log(` Dir: ${contractPath(entry.dir)}`); + if (entry.repo) console.log(` Repo: ${contractPath(entry.repo)}`); + console.log(` Prompt mode: ${entry.promptMode}`); + if (entry.model) console.log(` Model: ${entry.model}`); + if (entry.roles?.length) console.log(` Roles: ${entry.roles.join(', ')}`); + console.log(` Registered: ${entry.registeredAt}`); +} + +async function showEntry(name: string, json?: boolean): Promise<void> { + // Try user directory first, then resolve (includes built-ins) + const resolved = await directory.resolve(name); + if (!resolved) { + console.error(`Agent "${name}" not found in directory or built-ins.`); + process.exit(1); + } + + if (json) { + console.log(JSON.stringify({ ...resolved.entry, builtin: resolved.builtin }, null, 2)); + return; + } + + if (resolved.builtin) { + console.log(`\n(built-in ${resolved.entry.registeredAt === '(built-in)' ? 'agent' : 'agent'})`); + } + console.log(''); + printEntry(resolved.entry); + console.log(''); +} + +async function listEntries(json?: boolean, includeBuiltins?: boolean): Promise<void> { + const entries = await directory.ls(); + + if (json) { + listEntriesJson(entries, includeBuiltins); + return; + } + + if (entries.length === 0 && !includeBuiltins) { + console.log('\nNo agents registered. Add one with: genie dir add <name> --dir <path>'); + console.log('Use --builtins to also see built-in roles and council members.\n'); + return; + } + + if (entries.length > 0) { + printRegisteredTable(entries); + } + + if (includeBuiltins) { + printBuiltinsTable(); + } +} + +function listEntriesJson(entries: directory.DirectoryEntry[], includeBuiltins?: boolean): void { + const result: Record<string, unknown>[] = entries.map((e) => ({ ...e, builtin: false })); + if (includeBuiltins) { + for (const b of ALL_BUILTINS) { + result.push({ name: b.name, description: b.description, model: b.model, category: b.category, builtin: true }); + } + } + console.log(JSON.stringify(result, null, 2)); +} + +function printRegisteredTable(entries: directory.DirectoryEntry[]): void { + const nameW = 22; + const dirW = 35; + const modeW = 8; + const modelW = 8; + + console.log(''); + console.log('REGISTERED AGENTS'); + console.log('-'.repeat(80)); + console.log(` ${'NAME'.padEnd(nameW)}${'DIR'.padEnd(dirW)}${'MODE'.padEnd(modeW)}${'MODEL'.padEnd(modelW)}ROLES`); + console.log( + ` ${'-'.repeat(nameW - 2)} ${'-'.repeat(dirW - 2)} ${'-'.repeat(modeW - 2)} ${'-'.repeat(modelW - 2)} ${'-'.repeat(15)}`, + ); + + for (const entry of entries) { + const dir = contractPath(entry.dir); + const truncDir = dir.length > dirW - 2 ? `${dir.slice(0, dirW - 5)}...` : dir; + const roles = entry.roles?.join(', ') || '-'; + console.log( + ` ${entry.name.padEnd(nameW)}${truncDir.padEnd(dirW)}${entry.promptMode.padEnd(modeW)}${(entry.model || '-').padEnd(modelW)}${roles}`, + ); + } + console.log(''); +} + +function printBuiltinsTable(): void { + const nameW = 22; + const catW = 10; + const modelW = 8; + + console.log('BUILT-IN AGENTS'); + console.log('-'.repeat(80)); + console.log(` ${'NAME'.padEnd(nameW)}${'TYPE'.padEnd(catW)}${'MODEL'.padEnd(modelW)}DESCRIPTION`); + console.log(` ${'-'.repeat(nameW - 2)} ${'-'.repeat(catW - 2)} ${'-'.repeat(modelW - 2)} ${'-'.repeat(30)}`); + + for (const agent of ALL_BUILTINS) { + console.log( + ` ${agent.name.padEnd(nameW)}${agent.category.padEnd(catW)}${(agent.model || '-').padEnd(modelW)}${agent.description}`, + ); + } + console.log(''); +} diff --git a/src/term-commands/dispatch.test.ts b/src/term-commands/dispatch.test.ts new file mode 100644 index 000000000..3d0041c4b --- /dev/null +++ b/src/term-commands/dispatch.test.ts @@ -0,0 +1,504 @@ +/** + * Tests for dispatch commands — context injection and state machine integration. + * + * Tests: + * 1. extractGroup() — group section extraction from WISH.md + * 2. extractWishContext() — wish-level context extraction + * 3. buildContextPrompt() — prompt assembly + * 4. writeContextFile() — temp file creation + * 5. brainstormCommand() — DRAFT.md dispatch + * 6. wishCommand() — DESIGN.md dispatch + * 7. workDispatchCommand() — state check + group dispatch + * 8. reviewCommand() — review with diff context + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import * as wishState from '../lib/wish-state.js'; +import { parseRef } from './state.js'; + +import { buildContextPrompt, extractGroup, extractWishContext, writeContextFile } from './dispatch.js'; + +// ============================================================================ +// Sample WISH.md content for testing +// ============================================================================ + +const SAMPLE_WISH = `# Wish: Test Feature + +| Field | Value | +|-------|-------| +| **Status** | DRAFT | +| **Slug** | \`test-feature\` | + +## Summary + +This is a test wish for testing dispatch commands. + +## Scope + +### IN +- Feature A +- Feature B + +### OUT +- Feature C + +## Decisions + +| Decision | Rationale | +|----------|-----------| +| Use X | Because Y | + +## Execution Groups + +### Group 1: Foundation + +**Goal:** Build the foundation. + +**Deliverables:** +1. Create module A +2. Create module B + +**Acceptance criteria:** +- Module A works +- Module B works + +**depends-on:** none + +--- + +### Group 2: Integration + +**Goal:** Integrate modules. + +**Deliverables:** +1. Connect A to B +2. Add tests + +**Acceptance criteria:** +- Integration works +- Tests pass + +**depends-on:** Group 1 + +--- + +### Group 3: Polish + +**Goal:** Final polish. + +**Deliverables:** +1. Documentation +2. Performance tuning + +**Acceptance criteria:** +- Docs complete +- Performance acceptable + +**depends-on:** Group 2 +`; + +// ============================================================================ +// Unit Tests: Parsing Utilities +// ============================================================================ + +describe('parseRef()', () => { + it('should parse slug#group format', () => { + const result = parseRef('auth-bug#2'); + expect(result).toEqual({ slug: 'auth-bug', group: '2' }); + }); + + it('should parse slug with numbers', () => { + const result = parseRef('test-feature#1'); + expect(result).toEqual({ slug: 'test-feature', group: '1' }); + }); + + it('should handle multi-digit group numbers', () => { + const result = parseRef('big-wish#10'); + expect(result).toEqual({ slug: 'big-wish', group: '10' }); + }); + + it('should throw on missing hash', () => { + expect(() => parseRef('no-hash')).toThrow('Invalid reference'); + }); + + it('should throw on empty slug', () => { + expect(() => parseRef('#2')).toThrow('Invalid reference'); + }); + + it('should throw on empty group', () => { + expect(() => parseRef('slug#')).toThrow('Invalid reference'); + }); +}); + +describe('extractGroup()', () => { + it('should extract group 1 section', () => { + const result = extractGroup(SAMPLE_WISH, '1'); + expect(result).not.toBeNull(); + expect(result).toContain('### Group 1: Foundation'); + expect(result).toContain('Build the foundation'); + expect(result).toContain('Create module A'); + expect(result).toContain('**depends-on:** none'); + }); + + it('should extract group 2 section', () => { + const result = extractGroup(SAMPLE_WISH, '2'); + expect(result).not.toBeNull(); + expect(result).toContain('### Group 2: Integration'); + expect(result).toContain('Integrate modules'); + expect(result).toContain('Connect A to B'); + expect(result).toContain('**depends-on:** Group 1'); + }); + + it('should extract last group section', () => { + const result = extractGroup(SAMPLE_WISH, '3'); + expect(result).not.toBeNull(); + expect(result).toContain('### Group 3: Polish'); + expect(result).toContain('Documentation'); + expect(result).toContain('**depends-on:** Group 2'); + }); + + it('should not include content from the next group', () => { + const result = extractGroup(SAMPLE_WISH, '1'); + expect(result).not.toContain('### Group 2'); + expect(result).not.toContain('Integrate modules'); + }); + + it('should return null for non-existent group', () => { + const result = extractGroup(SAMPLE_WISH, '99'); + expect(result).toBeNull(); + }); + + it('should return null for empty content', () => { + const result = extractGroup('', '1'); + expect(result).toBeNull(); + }); +}); + +describe('extractWishContext()', () => { + it('should extract everything before Execution Groups', () => { + const result = extractWishContext(SAMPLE_WISH); + expect(result).toContain('# Wish: Test Feature'); + expect(result).toContain('## Summary'); + expect(result).toContain('test wish for testing'); + expect(result).toContain('## Scope'); + expect(result).toContain('## Decisions'); + expect(result).not.toContain('## Execution Groups'); + expect(result).not.toContain('### Group 1'); + }); + + it('should handle content without Execution Groups', () => { + const simple = '# Simple Wish\n\nJust a simple description.'; + const result = extractWishContext(simple); + expect(result).toBe(simple); + }); + + it('should truncate very long content without Execution Groups', () => { + const long = 'x'.repeat(3000); + const result = extractWishContext(long); + expect(result.length).toBeLessThanOrEqual(2000); + }); +}); + +describe('buildContextPrompt()', () => { + it('should include file path and section content', () => { + const result = buildContextPrompt({ + filePath: '/path/to/WISH.md', + sectionContent: 'Group content here', + command: 'work test#1', + }); + + expect(result).toContain('# Dispatch Context (work test#1)'); + expect(result).toContain('**Source file:** `/path/to/WISH.md`'); + expect(result).toContain('Group content here'); + }); + + it('should include wish context when provided', () => { + const result = buildContextPrompt({ + filePath: '/path/to/WISH.md', + sectionContent: 'Group content', + wishContext: 'Summary and scope info', + command: 'work test#1', + }); + + expect(result).toContain('## Wish Context'); + expect(result).toContain('Summary and scope info'); + }); + + it('should omit wish context section when not provided', () => { + const result = buildContextPrompt({ + filePath: '/path/to/file.md', + sectionContent: 'Content', + command: 'brainstorm', + }); + + expect(result).not.toContain('## Wish Context'); + }); + + it('should include skill command when provided', () => { + const result = buildContextPrompt({ + filePath: '/path/to/file.md', + sectionContent: 'Content', + command: 'work test#1', + skill: 'work', + }); + + expect(result).toContain('## Initial Command'); + expect(result).toContain('`/work`'); + }); + + it('should include read-full-document hint', () => { + const result = buildContextPrompt({ + filePath: '/path/to/file.md', + sectionContent: 'Content', + command: 'brainstorm', + }); + + expect(result).toContain('Read the full document at the path above'); + }); +}); + +describe('writeContextFile()', () => { + it('should write content to a temp file and return path', async () => { + const content = 'test context content'; + const filePath = await writeContextFile(content); + + expect(filePath).toContain('genie-dispatch'); + expect(filePath).toContain('ctx-'); + + const written = await readFile(filePath, 'utf-8'); + expect(written).toBe(content); + }); + + it('should create unique file names', async () => { + const path1 = await writeContextFile('content 1'); + const path2 = await writeContextFile('content 2'); + expect(path1).not.toBe(path2); + }); +}); + +// ============================================================================ +// Integration Tests: Dispatch Commands +// ============================================================================ + +describe('dispatch commands - file reading', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = join('/tmp', `dispatch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('brainstorm dispatch', () => { + it('should read DRAFT.md from .genie/brainstorms/<slug>/', async () => { + const slug = 'test-idea'; + const brainstormDir = join(tempDir, '.genie', 'brainstorms', slug); + await mkdir(brainstormDir, { recursive: true }); + const draftContent = '# Draft: Test Idea\n\nThis is a brainstorm draft.'; + await writeFile(join(brainstormDir, 'DRAFT.md'), draftContent); + + // Verify file can be found at the expected path + const draftPath = join(tempDir, '.genie', 'brainstorms', slug, 'DRAFT.md'); + const content = await readFile(draftPath, 'utf-8'); + expect(content).toBe(draftContent); + + // Build context prompt + const context = buildContextPrompt({ + filePath: draftPath, + sectionContent: content, + command: 'brainstorm', + skill: 'brainstorm', + }); + + expect(context).toContain(draftPath); + expect(context).toContain(draftContent); + expect(context).toContain('`/brainstorm`'); + }); + }); + + describe('wish dispatch', () => { + it('should read DESIGN.md from .genie/brainstorms/<slug>/', async () => { + const slug = 'test-design'; + const brainstormDir = join(tempDir, '.genie', 'brainstorms', slug); + await mkdir(brainstormDir, { recursive: true }); + const designContent = '# Design: Test Feature\n\n## Architecture\n\nUse modules.'; + await writeFile(join(brainstormDir, 'DESIGN.md'), designContent); + + const designPath = join(tempDir, '.genie', 'brainstorms', slug, 'DESIGN.md'); + const content = await readFile(designPath, 'utf-8'); + expect(content).toBe(designContent); + + const context = buildContextPrompt({ + filePath: designPath, + sectionContent: content, + command: 'wish', + skill: 'wish', + }); + + expect(context).toContain(designPath); + expect(context).toContain(designContent); + expect(context).toContain('`/wish`'); + }); + }); + + describe('work dispatch - group extraction', () => { + it('should extract correct group from WISH.md', async () => { + const slug = 'test-feature'; + const wishDir = join(tempDir, '.genie', 'wishes', slug); + await mkdir(wishDir, { recursive: true }); + await writeFile(join(wishDir, 'WISH.md'), SAMPLE_WISH); + + const wishPath = join(wishDir, 'WISH.md'); + const content = await readFile(wishPath, 'utf-8'); + + // Extract group 2 + const group = extractGroup(content, '2'); + expect(group).not.toBeNull(); + expect(group).toContain('Integration'); + expect(group).not.toContain('Foundation'); + expect(group).not.toContain('Polish'); + + // Build context with wish-level info + const wishContext = extractWishContext(content); + const context = buildContextPrompt({ + filePath: wishPath, + sectionContent: group!, + wishContext, + command: 'work test-feature#2', + skill: 'work', + }); + + expect(context).toContain(wishPath); + expect(context).toContain('Integration'); + expect(context).toContain('## Wish Context'); + expect(context).toContain('test wish for testing'); + }); + }); + + describe('review dispatch', () => { + it('should include group section in review context', async () => { + const slug = 'test-feature'; + const wishDir = join(tempDir, '.genie', 'wishes', slug); + await mkdir(wishDir, { recursive: true }); + await writeFile(join(wishDir, 'WISH.md'), SAMPLE_WISH); + + const content = await readFile(join(wishDir, 'WISH.md'), 'utf-8'); + const groupSection = extractGroup(content, '1'); + expect(groupSection).not.toBeNull(); + + // Build review content (simulating what reviewCommand does) + const reviewContent = [ + groupSection!, + '', + '## Git Diff (changes to review)', + '', + '(no uncommitted changes found)', + ].join('\n'); + + const context = buildContextPrompt({ + filePath: join(wishDir, 'WISH.md'), + sectionContent: reviewContent, + wishContext: extractWishContext(content), + command: 'review test-feature#1', + skill: 'review', + }); + + expect(context).toContain('review test-feature#1'); + expect(context).toContain('Foundation'); + expect(context).toContain('Git Diff'); + }); + }); +}); + +// ============================================================================ +// State Machine Integration +// ============================================================================ + +describe('dispatch commands - state machine integration', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = join('/tmp', `dispatch-state-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should allow starting group when dependencies are met', async () => { + // Create state with group 1 (no deps) and group 2 (depends on 1) + await wishState.createState( + 'test-wish', + [ + { name: '1', dependsOn: [] }, + { name: '2', dependsOn: ['1'] }, + ], + tempDir, + ); + + // Group 1 should be startable (no deps) + const g1 = await wishState.startGroup('test-wish', '1', 'agent-a', tempDir); + expect(g1.status).toBe('in_progress'); + expect(g1.assignee).toBe('agent-a'); + }); + + it('should refuse starting group when dependencies are not met', async () => { + await wishState.createState( + 'test-wish-2', + [ + { name: '1', dependsOn: [] }, + { name: '2', dependsOn: ['1'] }, + { name: '3', dependsOn: ['2'] }, + ], + tempDir, + ); + + // Group 2 should be blocked (group 1 not done) + expect(() => wishState.startGroup('test-wish-2', '2', 'agent-b', tempDir)).toThrow('dependency "1" is ready'); + }); + + it('should allow starting group 2 after group 1 is done', async () => { + await wishState.createState( + 'test-wish-3', + [ + { name: '1', dependsOn: [] }, + { name: '2', dependsOn: ['1'] }, + ], + tempDir, + ); + + // Start and complete group 1 + await wishState.startGroup('test-wish-3', '1', 'agent-a', tempDir); + await wishState.completeGroup('test-wish-3', '1', tempDir); + + // Now group 2 should be startable + const g2 = await wishState.startGroup('test-wish-3', '2', 'agent-b', tempDir); + expect(g2.status).toBe('in_progress'); + }); + + it('should refuse starting group 3 when group 2 is not done', async () => { + await wishState.createState( + 'test-wish-4', + [ + { name: '1', dependsOn: [] }, + { name: '2', dependsOn: ['1'] }, + { name: '3', dependsOn: ['2'] }, + ], + tempDir, + ); + + // Complete group 1, start group 2 (but don't complete it) + await wishState.startGroup('test-wish-4', '1', 'agent-a', tempDir); + await wishState.completeGroup('test-wish-4', '1', tempDir); + await wishState.startGroup('test-wish-4', '2', 'agent-b', tempDir); + + // Group 3 should be blocked (group 2 is in_progress, not done) + expect(() => wishState.startGroup('test-wish-4', '3', 'agent-c', tempDir)).toThrow('dependency "2" is in_progress'); + }); +}); diff --git a/src/term-commands/dispatch.ts b/src/term-commands/dispatch.ts new file mode 100644 index 000000000..8958102e9 --- /dev/null +++ b/src/term-commands/dispatch.ts @@ -0,0 +1,407 @@ +/** + * Dispatch Commands — Context-injecting spawn commands that bridge + * the wish state machine and agent spawn. + * + * Commands: + * genie brainstorm <agent> <slug> — Spawn with DRAFT.md content + * genie wish <agent> <slug> — Spawn with DESIGN.md content + * genie work <agent> <slug>#<group> — Check state, start group, spawn with context + * genie review <agent> <slug>#<group> — Spawn with group + git diff context + * + * All commands inject: + * 1. The file path to the full document (so agent can read it) + * 2. The extracted section content (so agent has immediate context) + * 3. Wish-level context when available (summary, scope, decisions) + * + * Context is written to a temp file and passed via --append-system-prompt-file + * through extraArgs in the spawn params. + */ + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Command } from 'commander'; +import type { GroupDefinition } from '../lib/wish-state.js'; +import * as wishState from '../lib/wish-state.js'; +import { handleWorkerSpawn } from './agents.js'; +import { parseRef } from './state.js'; + +// ============================================================================ +// Context Injection Utilities +// ============================================================================ + +/** + * Write dispatch context to a temp file. + * Returns the file path for use with --append-system-prompt-file. + */ +export async function writeContextFile(content: string): Promise<string> { + const dir = join(tmpdir(), 'genie-dispatch'); + await mkdir(dir, { recursive: true }); + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + const filePath = join(dir, `ctx-${ts}-${rand}.md`); + await writeFile(filePath, content); + return filePath; +} + +/** + * Extract a specific group section from WISH.md content. + * + * Groups are delimited by `### Group <N>: <title>` headings + * and separated by `---` lines or the next group heading. + */ +export function extractGroup(content: string, groupName: string): string | null { + const pattern = new RegExp(`^### Group ${escapeRegExp(groupName)}:`, 'm'); + const match = content.match(pattern); + if (!match || match.index === undefined) return null; + + const start = match.index; + const afterHeading = content.slice(start); + + // Find next group heading or HR separator + const nextBoundary = afterHeading.slice(1).search(/^### Group \d|^---$/m); + const end = nextBoundary !== -1 ? start + 1 + nextBoundary : content.length; + + return content.slice(start, end).trim(); +} + +/** + * Extract wish-level context (everything before ## Execution Groups). + * This includes Summary, Scope, and Decisions sections. + */ +export function extractWishContext(content: string): string { + const execGroupsIdx = content.indexOf('## Execution Groups'); + if (execGroupsIdx !== -1) { + return content.slice(0, execGroupsIdx).trim(); + } + // If no execution groups section, return the whole content up to 2000 chars + return content.slice(0, 2000).trim(); +} + +/** Escape regex special characters. */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Build a dispatch context prompt that gives the agent: + * - The file path to read the full document + * - The extracted section content + * - Optional wish-level context + */ +export function buildContextPrompt(opts: { + filePath: string; + sectionContent: string; + wishContext?: string; + command: string; + skill?: string; +}): string { + const parts = [ + `# Dispatch Context (${opts.command})`, + '', + `**Source file:** \`${opts.filePath}\``, + '(Read the full document at the path above for complete context)', + '', + ]; + + if (opts.wishContext) { + parts.push('## Wish Context', '', opts.wishContext, ''); + } + + parts.push('## Assigned Section', '', opts.sectionContent, ''); + + if (opts.skill) { + parts.push('## Initial Command', '', `Run \`/${opts.skill}\` to begin.`, ''); + } + + return parts.join('\n'); +} + +/** + * Get git diff context for review commands. + * Returns the diff output or empty string if no changes. + */ +function getGitDiff(): string { + try { + const diff = execSync('git diff HEAD', { encoding: 'utf-8', maxBuffer: 1024 * 1024 }); + const staged = execSync('git diff --cached', { encoding: 'utf-8', maxBuffer: 1024 * 1024 }); + const combined = [diff, staged].filter(Boolean).join('\n'); + // Limit diff size to avoid overwhelming the context + if (combined.length > 50000) { + return `${combined.slice(0, 50000)}\n\n... (diff truncated at 50KB)`; + } + return combined; + } catch { + return ''; + } +} + +/** + * Parse WISH.md content to extract group definitions for state initialization. + * Looks for `### Group <N>: <title>` headings and `**depends-on:**` lines. + */ +export function parseWishGroups(content: string): GroupDefinition[] { + const groups: GroupDefinition[] = []; + const groupPattern = /^### Group (\d+):/gm; + + let match: RegExpExecArray | null; + while ((match = groupPattern.exec(content)) !== null) { + const name = match[1]; + const start = match.index; + + // Find the next group heading or end of content + const rest = content.slice(start + match[0].length); + const nextGroupIdx = rest.search(/^### Group \d+:/m); + const section = nextGroupIdx !== -1 ? rest.slice(0, nextGroupIdx) : rest; + + // Look for **depends-on:** line within this group section + const depsMatch = section.match(/\*\*depends-on:\*\*\s*(.+)/i); + let dependsOn: string[] = []; + if (depsMatch) { + const depsStr = depsMatch[1].trim(); + if (depsStr.toLowerCase() !== 'none') { + dependsOn = depsStr + .split(',') + .map((d) => d.trim().replace(/^group\s*/i, '')) + .filter(Boolean); + } + } + + groups.push({ name, dependsOn }); + } + + return groups; +} + +// ============================================================================ +// Dispatch Commands +// ============================================================================ + +/** + * `genie brainstorm <agent> <slug>` — Read DRAFT.md, spawn agent with content. + */ +export async function brainstormCommand(agentName: string, slug: string): Promise<void> { + const draftPath = join(process.cwd(), '.genie', 'brainstorms', slug, 'DRAFT.md'); + + if (!existsSync(draftPath)) { + console.error(`❌ Draft not found: ${draftPath}`); + console.error( + ` Create it first: mkdir -p .genie/brainstorms/${slug} && touch .genie/brainstorms/${slug}/DRAFT.md`, + ); + process.exit(1); + } + + const content = await readFile(draftPath, 'utf-8'); + const context = buildContextPrompt({ + filePath: draftPath, + sectionContent: content, + command: 'brainstorm', + skill: 'brainstorm', + }); + + const contextFile = await writeContextFile(context); + console.log(`📝 Dispatching brainstorm to ${agentName} for "${slug}"`); + console.log(` Draft: ${draftPath}`); + + await handleWorkerSpawn(agentName, { + provider: 'claude', + team: process.env.GENIE_TEAM ?? 'genie', + extraArgs: ['--append-system-prompt-file', contextFile], + }); +} + +/** + * `genie wish <agent> <slug>` — Read DESIGN.md, spawn agent with content. + */ +export async function wishCommand(agentName: string, slug: string): Promise<void> { + const designPath = join(process.cwd(), '.genie', 'brainstorms', slug, 'DESIGN.md'); + + if (!existsSync(designPath)) { + console.error(`❌ Design not found: ${designPath}`); + console.error(` Run brainstorm first: genie brainstorm <agent> ${slug}`); + process.exit(1); + } + + const content = await readFile(designPath, 'utf-8'); + const context = buildContextPrompt({ + filePath: designPath, + sectionContent: content, + command: 'wish', + skill: 'wish', + }); + + const contextFile = await writeContextFile(context); + console.log(`📝 Dispatching wish to ${agentName} for "${slug}"`); + console.log(` Design: ${designPath}`); + + await handleWorkerSpawn(agentName, { + provider: 'claude', + team: process.env.GENIE_TEAM ?? 'genie', + extraArgs: ['--append-system-prompt-file', contextFile], + }); +} + +/** + * `genie work <agent> <slug>#<group>` — Check state, start group, spawn with context. + * + * Flow: + * 1. Parse ref (slug#group) + * 2. Read WISH.md + * 3. Extract group section + * 4. Call wishState.startGroup() (enforces dependency order) + * 5. Build context with wish-level info + group section + * 6. Spawn agent + */ +export async function workDispatchCommand(agentName: string, ref: string): Promise<void> { + const { slug, group } = parseRef(ref); + const wishPath = join(process.cwd(), '.genie', 'wishes', slug, 'WISH.md'); + + if (!existsSync(wishPath)) { + console.error(`❌ Wish not found: ${wishPath}`); + console.error(` Create it first: genie wish <agent> ${slug}`); + process.exit(1); + } + + const content = await readFile(wishPath, 'utf-8'); + + // Extract the specific group section + const groupSection = extractGroup(content, group); + if (!groupSection) { + console.error(`❌ Group "${group}" not found in ${wishPath}`); + console.error(' Available groups:'); + const groups = content.match(/^### Group \d+:.*$/gm); + if (groups) { + for (const g of groups) console.error(` ${g}`); + } + process.exit(1); + } + + // Auto-initialize state if no state file exists + let state = await wishState.getState(slug); + if (!state) { + const groups = parseWishGroups(content); + state = await wishState.createState(slug, groups); + console.log(`📝 Initialized state for wish "${slug}" (${groups.length} groups)`); + } + + // Start group in state machine (enforces dependencies) + try { + await wishState.startGroup(slug, group, agentName); + console.log(`✅ Group "${group}" set to in_progress (assigned to ${agentName})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ ${message}`); + process.exit(1); + } + + // Build context with wish-level info + group section + const wishContext = extractWishContext(content); + const context = buildContextPrompt({ + filePath: wishPath, + sectionContent: groupSection, + wishContext, + command: `work ${ref}`, + skill: 'work', + }); + + const contextFile = await writeContextFile(context); + console.log(`🔧 Dispatching work to ${agentName} for "${ref}"`); + console.log(` Wish: ${wishPath}`); + console.log(` Group: ${group}`); + + await handleWorkerSpawn(agentName, { + provider: 'claude', + team: process.env.GENIE_TEAM ?? 'genie', + extraArgs: ['--append-system-prompt-file', contextFile], + }); +} + +/** + * `genie review <agent> <slug>#<group>` — Spawn with group + git diff context. + */ +export async function reviewCommand(agentName: string, ref: string): Promise<void> { + const { slug, group } = parseRef(ref); + const wishPath = join(process.cwd(), '.genie', 'wishes', slug, 'WISH.md'); + + if (!existsSync(wishPath)) { + console.error(`❌ Wish not found: ${wishPath}`); + process.exit(1); + } + + const content = await readFile(wishPath, 'utf-8'); + + // Extract the specific group section + const groupSection = extractGroup(content, group); + if (!groupSection) { + console.error(`❌ Group "${group}" not found in ${wishPath}`); + process.exit(1); + } + + // Get git diff for review context + const diff = getGitDiff(); + + const wishContext = extractWishContext(content); + const reviewContent = [ + groupSection, + '', + '## Git Diff (changes to review)', + '', + diff ? `\`\`\`diff\n${diff}\n\`\`\`` : '(no uncommitted changes found — review committed changes)', + ].join('\n'); + + const context = buildContextPrompt({ + filePath: wishPath, + sectionContent: reviewContent, + wishContext, + command: `review ${ref}`, + skill: 'review', + }); + + const contextFile = await writeContextFile(context); + console.log(`🔍 Dispatching review to ${agentName} for "${ref}"`); + console.log(` Wish: ${wishPath}`); + console.log(` Group: ${group}`); + if (diff) console.log(` Diff: ${diff.split('\n').length} lines`); + + await handleWorkerSpawn(agentName, { + provider: 'claude', + team: process.env.GENIE_TEAM ?? 'genie', + extraArgs: ['--append-system-prompt-file', contextFile], + }); +} + +// ============================================================================ +// Registration +// ============================================================================ + +export function registerDispatchCommands(program: Command): void { + program + .command('brainstorm <agent> <slug>') + .description('Spawn agent with brainstorm DRAFT.md context') + .action(async (agent: string, slug: string) => { + await brainstormCommand(agent, slug); + }); + + program + .command('wish <agent> <slug>') + .description('Spawn agent with wish DESIGN.md context') + .action(async (agent: string, slug: string) => { + await wishCommand(agent, slug); + }); + + program + .command('work <agent> <ref>') + .description('Dispatch work on a wish group (format: <slug>#<group>)') + .action(async (agent: string, ref: string) => { + await workDispatchCommand(agent, ref); + }); + + program + .command('review <agent> <ref>') + .description('Spawn agent with review scope for a wish group (format: <slug>#<group>)') + .action(async (agent: string, ref: string) => { + await reviewCommand(agent, ref); + }); +} diff --git a/src/term-commands/events.ts b/src/term-commands/events.ts index 75ef79baa..c135ef333 100644 --- a/src/term-commands/events.ts +++ b/src/term-commands/events.ts @@ -22,24 +22,12 @@ import { appendFile, mkdir, readFile, readdir, unlink } from 'node:fs/promises'; import { join } from 'node:path'; -import * as registry from '../lib/agent-registry.js'; -import { type ClaudeLogEntry, getLogsForPane, readLogFile, tailLogFile } from '../lib/claude-logs.js'; -import * as tmux from '../lib/tmux.js'; +import type { ClaudeLogEntry } from '../lib/claude-logs.js'; // ============================================================================ // Types // ============================================================================ -interface EventsOptions { - json?: boolean; - follow?: boolean; - lines?: number; - /** Write events to .genie/events/<pane-id>.jsonl while tailing */ - emit?: boolean; - /** Aggregate events from all active workers */ - all?: boolean; -} - /** * Normalized event format for orchestration */ @@ -60,7 +48,7 @@ export interface NormalizedEvent { paneId?: string; /** Wish slug (e.g., "wish-21") */ wishId?: string; - /** Task/beads ID (e.g., "bd-123") */ + /** Task ID */ taskId?: string; // Tool call specific fields @@ -277,227 +265,3 @@ export function parseLogEntryToEvent(entry: ClaudeLogEntry, workerContext?: Work return null; } - -// ============================================================================ -// Pane Resolution -// ============================================================================ - -/** - * Get the working directory for a tmux pane - */ -async function getPaneWorkdir(paneId: string): Promise<string | undefined> { - try { - // Normalize pane ID - const normalizedPaneId = paneId.startsWith('%') ? paneId : `%${paneId}`; - const result = await tmux.executeTmux(`display-message -p -t '${normalizedPaneId}' '#{pane_current_path}'`); - return result.trim() || undefined; - } catch { - return undefined; - } -} - -// ============================================================================ -// Main Command -// ============================================================================ - -/** - * Resolve pane context: worker info, working directory, and log path. - */ -async function resolvePaneContext(paneId: string) { - const normalizedPaneId = paneId.startsWith('%') ? paneId : `%${paneId}`; - - const worker = await registry.findByPane(normalizedPaneId); - const workerContext: WorkerContext | undefined = worker - ? { paneId: worker.paneId, wishSlug: worker.wishSlug, taskId: worker.taskId } - : undefined; - - let workdir = worker?.repoPath; - if (!workdir) { - workdir = await getPaneWorkdir(normalizedPaneId); - } - - if (!workdir) { - console.error(`Could not determine working directory for pane ${normalizedPaneId}`); - process.exit(1); - } - - const logInfo = await getLogsForPane(workdir); - if (!logInfo) { - console.error(`No Claude Code logs found for pane ${normalizedPaneId}`); - console.error(`Working directory: ${workdir}`); - process.exit(1); - } - - return { normalizedPaneId, workerContext, logInfo }; -} - -/** - * Tail mode: stream events in real-time, optionally writing to event file. - */ -async function tailEvents( - logPath: string, - normalizedPaneId: string, - workerContext: WorkerContext | undefined, - options: EventsOptions, -): Promise<void> { - if (options.emit) { - console.error(`Writing events to ${getEventFilePath(normalizedPaneId)}...`); - } - console.error(`Tailing events from ${logPath}...`); - console.error('Press Ctrl+C to stop.\n'); - - const cleanup = await tailLogFile(logPath, async (entry) => { - const event = parseLogEntryToEvent(entry, workerContext); - if (!event) return; - if (!options.emit || options.json || options.follow) { - outputEvent(event, options); - } - if (options.emit) { - await writeEventToFile(event, normalizedPaneId); - } - }); - - process.on('SIGINT', () => { - cleanup(); - process.exit(0); - }); - await new Promise(() => {}); -} - -/** - * Read mode: show recent events from log file. - */ -async function readEvents( - logPath: string, - workerContext: WorkerContext | undefined, - options: EventsOptions, -): Promise<void> { - const entries = await readLogFile(logPath); - const events: NormalizedEvent[] = []; - for (const entry of entries) { - const event = parseLogEntryToEvent(entry, workerContext); - if (event) events.push(event); - } - - const limit = options.lines || 20; - const recentEvents = events.slice(-limit); - - if (options.json) { - console.log(JSON.stringify(recentEvents, null, 2)); - return; - } - if (recentEvents.length === 0) { - console.log('No events found.'); - return; - } - for (const event of recentEvents) { - outputEvent(event, options); - } -} - -/** - * Main events command entry point. - * Supports single pane, all workers, follow mode, and emit mode. - */ -export async function eventsCommand(paneId: string | undefined, options: EventsOptions = {}): Promise<void> { - try { - if (options.all) { - await eventsAllCommand(options); - return; - } - - if (!paneId) { - console.error('Error: pane-id is required (or use --all for all agents)'); - process.exit(1); - } - - const { normalizedPaneId, workerContext, logInfo } = await resolvePaneContext(paneId); - - if (options.follow || options.emit) { - await tailEvents(logInfo.logPath, normalizedPaneId, workerContext, options); - } else { - await readEvents(logInfo.logPath, workerContext, options); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } -} - -/** - * Handle --all mode: aggregate events from all active workers. - * Reads from event files in .genie/events/ - */ -async function eventsAllCommand(options: EventsOptions): Promise<void> { - // Get all active workers - const workers = await registry.list(); - - if (workers.length === 0) { - if (options.json) { - console.log('[]'); - } else { - console.log('No active agents found.'); - } - return; - } - - // Aggregate events from all event files - const allEvents = await aggregateAllEvents(); - - // Filter to events from active workers only - const activePaneIds = new Set(workers.map((w) => w.paneId)); - const activeEvents = allEvents.filter((e) => e.paneId && activePaneIds.has(e.paneId)); - - // Limit to last N events - const limit = options.lines || 50; - const recentEvents = activeEvents.slice(-limit); - - if (options.json) { - console.log(JSON.stringify(recentEvents, null, 2)); - } else { - if (recentEvents.length === 0) { - console.log('No events found from active agents.'); - console.log('Hint: Use --emit flag when tailing to write events to files.'); - } else { - for (const event of recentEvents) { - outputEvent(event, options); - } - } - } -} - -/** - * Output a single event to stdout - */ -function outputEvent(event: NormalizedEvent, options: EventsOptions): void { - if (options.json) { - console.log(JSON.stringify(event)); - } else { - // Human-readable format - const time = new Date(event.timestamp).toLocaleTimeString(); - const context = event.wishId ? `[${event.wishId}] ` : ''; - - switch (event.type) { - case 'session_start': - console.log(`${time} ${context}SESSION_START - ${event.cwd}`); - break; - case 'tool_call': { - const input = event.toolInput - ? Object.entries(event.toolInput) - .slice(0, 2) - .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 30)}`) - .join(', ') - : ''; - console.log(`${time} ${context}TOOL_CALL - ${event.toolName}(${input})`); - break; - } - case 'permission_request': - console.log(`${time} ${context}PERMISSION_REQUEST - ${event.toolName || 'unknown'}`); - break; - case 'session_end': - console.log(`${time} ${context}SESSION_END - ${event.exitReason || 'completed'}`); - break; - } - } -} diff --git a/src/term-commands/exec.ts b/src/term-commands/exec.ts deleted file mode 100644 index 0560887f4..000000000 --- a/src/term-commands/exec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getTerminalConfig } from '../lib/genie-config.js'; -import { formatResolvedLabel, resolveTarget } from '../lib/target-resolver.js'; -import * as tmux from '../lib/tmux.js'; - -export interface ExecOptions { - quiet?: boolean; - timeout?: number; -} - -export async function executeInSession(target: string, command: string, options: ExecOptions = {}): Promise<void> { - try { - // Use target resolver (DEC-1 from wish-26) - const resolved = await resolveTarget(target); - const paneId = resolved.paneId; - const resolvedLabel = formatResolvedLabel(resolved, target); - - // Use config default if no timeout specified - const termConfig = getTerminalConfig(); - const timeout = options.timeout ?? termConfig.execTimeout; - - // Run command synchronously using wait-for (no polling, no ugly markers) - const { output, exitCode } = await tmux.runCommandSync(paneId, command, timeout); - - // Output the result (unless quiet mode) - if (output && !options.quiet) { - console.log(output); - } - - // Log resolution confirmation to stderr (so stdout stays clean for exec output) - if (!options.quiet) { - console.error(`Executed in ${resolvedLabel}`); - } - - process.exit(exitCode); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } -} diff --git a/src/term-commands/history.ts b/src/term-commands/history.ts index 6be5c437d..52d506e3a 100644 --- a/src/term-commands/history.ts +++ b/src/term-commands/history.ts @@ -5,10 +5,10 @@ * Claude's JSONL logs and extracting key events. * * Usage: - * genie agent history <agent> # Compressed summary - * genie agent history <agent> --full # Full conversation - * genie agent history <agent> --since 5 # Last 5 exchanges - * genie agent history <agent> --json # JSON output + * genie history <agent> # Compressed summary + * genie history <agent> --full # Full conversation + * genie history <agent> --since 5 # Last 5 exchanges + * genie history <agent> --json # JSON output */ import * as workerRegistry from '../lib/agent-registry.js'; @@ -430,7 +430,7 @@ async function resolveLogContext(workerIdOrName: string, options: HistoryOptions const worker = await findWorker(workerIdOrName); if (!worker) { console.error(`❌ Agent "${workerIdOrName}" not found.`); - console.error(' Run `genie agent list` to see active agents.'); + console.error(' Run `genie ls` to see agents.'); process.exit(1); } diff --git a/src/term-commands/msg.test.ts b/src/term-commands/msg.test.ts index bf35fda73..11d516aaa 100644 --- a/src/term-commands/msg.test.ts +++ b/src/term-commands/msg.test.ts @@ -1,27 +1,27 @@ /** - * Sender Identity Detection — Regression Tests + * Messaging Commands — Regression Tests * - * Covers the detectSenderIdentity cascade to prevent identity bugs - * where messages arrive as "genie" or "cli" instead of the correct sender. - * - * Scenarios: - * 1. Team-lead calling genie send via CC Bash tool (GENIE_AGENT_NAME set) - * 2. Worker calling genie send (GENIE_AGENT_NAME set by provider-adapters) - * 3. External CLI without context (no env, no tmux → fallback to 'cli') - * 4. GENIE_AGENT_NAME priority over TMUX_PANE - * 5. TMUX_PANE fallback when no env var and no registry match + * Covers: + * - detectSenderIdentity cascade + * - checkSendScope team enforcement + * - buildTeamLeadCommand shared module + * - provider-adapters GENIE_AGENT_NAME * * Run with: bun test src/term-commands/msg.test.ts */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { detectSenderIdentity } from './msg.js'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { checkSendScope, detectSenderIdentity } from './msg.js'; // --------------------------------------------------------------------------- // Helpers: save/restore env vars // --------------------------------------------------------------------------- -const ENV_KEYS = ['GENIE_AGENT_NAME', 'TMUX_PANE', 'CLAUDE_CONFIG_DIR', 'GENIE_HOME'] as const; +const ENV_KEYS = ['GENIE_AGENT_NAME', 'TMUX_PANE', 'CLAUDE_CONFIG_DIR', 'GENIE_HOME', 'GENIE_TEAM'] as const; let savedEnv: Record<string, string | undefined>; beforeEach(() => { @@ -93,6 +93,137 @@ describe('detectSenderIdentity', () => { const sender = await detectSenderIdentity('nonexistent-team'); expect(sender).toBe('cli'); }); + + // Scenario 6: Works with no teamName (optional parameter) + test('works without teamName parameter', async () => { + process.env.GENIE_AGENT_NAME = 'my-agent'; + + const sender = await detectSenderIdentity(); + expect(sender).toBe('my-agent'); + }); + + // Scenario 7: Falls back to GENIE_TEAM env when no teamName provided + test('uses GENIE_TEAM env when teamName not provided', async () => { + process.env.GENIE_AGENT_NAME = undefined; + process.env.TMUX_PANE = undefined; + process.env.GENIE_TEAM = 'test-team'; + + const sender = await detectSenderIdentity(); + expect(sender).toBe('cli'); // No TMUX_PANE → still falls through to cli + }); +}); + +// --------------------------------------------------------------------------- +// checkSendScope tests +// --------------------------------------------------------------------------- + +describe('checkSendScope', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'scope-test-')); + // Create .genie/teams directory + await mkdir(join(tempDir, '.genie', 'teams'), { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test('cli sender has no scope restriction', async () => { + const error = await checkSendScope(tempDir, 'cli', 'anyone'); + expect(error).toBeNull(); + }); + + test('sender not in any team has no scope restriction', async () => { + const error = await checkSendScope(tempDir, 'free-agent', 'anyone'); + expect(error).toBeNull(); + }); + + test('allows sending within same team', async () => { + // Create team with both sender and recipient as members + const teamConfig = { + name: 'test-team', + repo: tempDir, + baseBranch: 'dev', + worktreePath: join(tempDir, '.worktrees', 'test-team'), + members: ['alice', 'bob'], + createdAt: new Date().toISOString(), + }; + await writeFile(join(tempDir, '.genie', 'teams', 'test-team.json'), JSON.stringify(teamConfig)); + + const error = await checkSendScope(tempDir, 'alice', 'bob'); + expect(error).toBeNull(); + }); + + test('rejects sending to non-team-member', async () => { + const teamConfig = { + name: 'test-team', + repo: tempDir, + baseBranch: 'dev', + worktreePath: join(tempDir, '.worktrees', 'test-team'), + members: ['alice'], + createdAt: new Date().toISOString(), + }; + await writeFile(join(tempDir, '.genie', 'teams', 'test-team.json'), JSON.stringify(teamConfig)); + + const error = await checkSendScope(tempDir, 'alice', 'outsider'); + expect(error).not.toBeNull(); + expect(error).toContain('Scope violation'); + expect(error).toContain('outsider'); + }); + + test('team-lead can always send to team-lead recipient', async () => { + const teamConfig = { + name: 'my-team', + repo: tempDir, + baseBranch: 'dev', + worktreePath: join(tempDir, '.worktrees', 'my-team'), + members: ['implementor'], + createdAt: new Date().toISOString(), + }; + await writeFile(join(tempDir, '.genie', 'teams', 'my-team.json'), JSON.stringify(teamConfig)); + + // implementor (member) can send to team-lead + const error = await checkSendScope(tempDir, 'implementor', 'team-lead'); + expect(error).toBeNull(); + }); + + test('team-lead uses GENIE_TEAM for team lookup', async () => { + const teamConfig = { + name: 'leader-team', + repo: tempDir, + baseBranch: 'dev', + worktreePath: join(tempDir, '.worktrees', 'leader-team'), + members: ['worker-a', 'worker-b'], + createdAt: new Date().toISOString(), + }; + await writeFile(join(tempDir, '.genie', 'teams', 'leader-team.json'), JSON.stringify(teamConfig)); + + process.env.GENIE_TEAM = 'leader-team'; + + // team-lead can send to team member + const error = await checkSendScope(tempDir, 'team-lead', 'worker-a'); + expect(error).toBeNull(); + }); + + test('team-lead blocked from sending to non-member', async () => { + const teamConfig = { + name: 'leader-team', + repo: tempDir, + baseBranch: 'dev', + worktreePath: join(tempDir, '.worktrees', 'leader-team'), + members: ['worker-a'], + createdAt: new Date().toISOString(), + }; + await writeFile(join(tempDir, '.genie', 'teams', 'leader-team.json'), JSON.stringify(teamConfig)); + + process.env.GENIE_TEAM = 'leader-team'; + + const error = await checkSendScope(tempDir, 'team-lead', 'outsider'); + expect(error).not.toBeNull(); + expect(error).toContain('Scope violation'); + }); }); // --------------------------------------------------------------------------- @@ -124,29 +255,29 @@ describe('buildTeamLeadCommand (shared module)', () => { expect(cmd).toContain('abc-123'); }); - test('includes --append-system-prompt via $(cat) when systemPrompt provided (default promptMode)', async () => { + test('includes --append-system-prompt-file when systemPrompt provided (default promptMode)', async () => { const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js'); const cmd = buildTeamLeadCommand('genie', { systemPrompt: 'test prompt' }); - expect(cmd).toContain('--append-system-prompt'); - expect(cmd).toContain('$(cat'); + expect(cmd).toContain('--append-system-prompt-file'); expect(cmd).toContain('.genie/prompts/genie.md'); // Prompt content is in the file, NOT inlined in the command expect(cmd).not.toContain('test prompt'); }); - test('system prompt is persisted to file, not flattened inline', async () => { + test('system prompt is persisted to file, referenced by path', async () => { const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js'); const cmd = buildTeamLeadCommand('genie', { systemPrompt: 'line one\nline two' }); - // Command references file, does not contain prompt text - expect(cmd).toContain('$(cat'); + // Command references file path, does not contain prompt text + expect(cmd).toContain('--append-system-prompt-file'); + expect(cmd).toContain('.genie/prompts/genie.md'); expect(cmd).not.toContain('line one'); }); - test('uses --system-prompt flag when promptMode is "system"', async () => { + test('uses --system-prompt-file flag when promptMode is "system"', async () => { const { buildTeamLeadCommand } = await import('../lib/team-lead-command.js'); const cmd = buildTeamLeadCommand('genie', { systemPrompt: 'test prompt', promptMode: 'system' }); - expect(cmd).toContain('--system-prompt'); - expect(cmd).not.toContain('--append-system-prompt'); + expect(cmd).toContain('--system-prompt-file'); + expect(cmd).not.toContain('--append-system-prompt-file'); }); }); @@ -195,13 +326,13 @@ describe('provider-adapters: GENIE_AGENT_NAME for workers', () => { expect(result.env!.GENIE_AGENT_NAME).toBe('implementor'); }); - test('buildClaudeCommand without nativeTeam does NOT set GENIE_AGENT_NAME', async () => { + test('buildClaudeCommand without nativeTeam still sets GENIE_AGENT_NAME from role', async () => { const { buildClaudeCommand } = await import('../lib/provider-adapters.js'); const result = buildClaudeCommand({ provider: 'claude', team: 'genie', role: 'implementor', }); - expect(result.env?.GENIE_AGENT_NAME).toBeUndefined(); + expect(result.env?.GENIE_AGENT_NAME).toBe('implementor'); }); }); diff --git a/src/term-commands/msg.ts b/src/term-commands/msg.ts index a07a914c0..3900d5653 100644 --- a/src/term-commands/msg.ts +++ b/src/term-commands/msg.ts @@ -1,9 +1,12 @@ /** - * Send / Inbox — Mailbox-first messaging between agents. + * Send / Inbox / Broadcast / Chat — Messaging commands between agents. * * Commands: - * genie send --to <agent> <body> - * genie inbox <agent> + * genie send '<msg>' --to <name> — Direct message (directory-first resolution) + * genie broadcast '<msg>' — Leader sends to all team members (one-way) + * genie inbox [<name>] [--unread] — View message inbox + * genie chat '<msg>' [--team <name>] — Post to team chat channel + * genie chat read [--team <name>] [--since <ts>] — Read team chat channel */ import { readFile } from 'node:fs/promises'; @@ -12,27 +15,41 @@ import { join } from 'node:path'; import type { Command } from 'commander'; import type * as registryTypes from '../lib/agent-registry.js'; import type * as protocolRouterTypes from '../lib/protocol-router.js'; +import type * as teamChatTypes from '../lib/team-chat.js'; +import type * as teamManagerTypes from '../lib/team-manager.js'; + +// ============================================================================ +// Lazy Loaders (avoid pulling heavy deps at module-evaluation time) +// ============================================================================ -// Lazy-load agent-registry to avoid pulling in the full module -// at evaluation time (fixes flaky bun module-resolution errors in CI). let _registry: typeof registryTypes | undefined; async function getRegistry(): Promise<typeof registryTypes> { - if (!_registry) { - _registry = await import('../lib/agent-registry.js'); - } + if (!_registry) _registry = await import('../lib/agent-registry.js'); return _registry; } -// Lazy-load protocol-router to avoid pulling in the full tmux dependency -// chain at module-evaluation time (fixes flaky bun module-resolution errors in CI). let _protocolRouter: typeof protocolRouterTypes | undefined; async function getProtocolRouter(): Promise<typeof protocolRouterTypes> { - if (!_protocolRouter) { - _protocolRouter = await import('../lib/protocol-router.js'); - } + if (!_protocolRouter) _protocolRouter = await import('../lib/protocol-router.js'); return _protocolRouter; } +let _teamManager: typeof teamManagerTypes | undefined; +async function getTeamManager(): Promise<typeof teamManagerTypes> { + if (!_teamManager) _teamManager = await import('../lib/team-manager.js'); + return _teamManager; +} + +let _teamChat: typeof teamChatTypes | undefined; +async function getTeamChat(): Promise<typeof teamChatTypes> { + if (!_teamChat) _teamChat = await import('../lib/team-chat.js'); + return _teamChat; +} + +// ============================================================================ +// Sender Identity Detection +// ============================================================================ + /** * Auto-detect the sender identity based on execution context. * @@ -42,7 +59,7 @@ async function getProtocolRouter(): Promise<typeof protocolRouterTypes> { * 3. TMUX_PANE → native team config members (match tmuxPaneId) → name * 4. Fallback: 'cli' */ -export async function detectSenderIdentity(teamName: string): Promise<string> { +export async function detectSenderIdentity(teamName?: string): Promise<string> { const envName = process.env.GENIE_AGENT_NAME; if (envName) return envName; @@ -55,7 +72,13 @@ export async function detectSenderIdentity(teamName: string): Promise<string> { const worker = typeof registry.findByPane === 'function' ? await registry.findByPane(paneId) : null; if (worker) return worker.role ?? worker.id; - return (await findMemberByPane(teamName, paneId)) ?? 'cli'; + const resolvedTeam = teamName ?? process.env.GENIE_TEAM; + if (resolvedTeam) { + const memberName = await findMemberByPane(resolvedTeam, paneId); + if (memberName) return memberName; + } + + return 'cli'; } /** Look up a pane ID in the native team config and return the member name. */ @@ -74,6 +97,70 @@ async function findMemberByPane(teamName: string, paneId: string): Promise<strin } } +// ============================================================================ +// Scope Checking +// ============================================================================ + +/** + * Enforce team scope: if sender is in a team, recipient must be in the same team. + * Returns an error message if scope is violated, null if OK. + */ +export async function checkSendScope(repoPath: string, sender: string, recipient: string): Promise<string | null> { + if (sender === 'cli') return null; + + const teamManager = await getTeamManager(); + const teams = await teamManager.listTeams(repoPath); + + // Find teams where sender is a member + let senderTeams = teams.filter((t) => t.members.includes(sender)); + + // team-lead belongs to the team indicated by GENIE_TEAM + if (sender === 'team-lead') { + const envTeam = process.env.GENIE_TEAM; + if (envTeam) { + const leaderTeam = teams.find((t) => t.name === envTeam); + if (leaderTeam && !senderTeams.some((t) => t.name === leaderTeam.name)) { + senderTeams = [...senderTeams, leaderTeam]; + } + } + } + + // Not in any team → no scope restriction + if (senderTeams.length === 0) return null; + + // Recipient is valid if they're in any of sender's teams (as member or team-lead) + for (const team of senderTeams) { + if (team.members.includes(recipient) || recipient === 'team-lead') return null; + } + + const teamNames = senderTeams.map((t) => t.name).join(', '); + return `Scope violation: "${recipient}" is not in sender's team(s): ${teamNames}`; +} + +/** + * Find the team for a given agent (for broadcast and chat auto-detection). + */ +async function findAgentTeam(repoPath: string, agentName: string): Promise<teamManagerTypes.TeamConfig | null> { + const teamManager = await getTeamManager(); + const teams = await teamManager.listTeams(repoPath); + + // Check membership + const memberTeam = teams.find((t) => t.members.includes(agentName)); + if (memberTeam) return memberTeam; + + // team-lead uses GENIE_TEAM env + if (agentName === 'team-lead') { + const envTeam = process.env.GENIE_TEAM; + if (envTeam) return teams.find((t) => t.name === envTeam) ?? null; + } + + return null; +} + +// ============================================================================ +// Display Helpers +// ============================================================================ + function printInbox( worker: string, messages: Awaited<ReturnType<typeof protocolRouterTypes.getInbox>>, @@ -98,20 +185,48 @@ function printInbox( } } +function printChatMessages(teamName: string, messages: teamChatTypes.ChatMessage[]): void { + if (messages.length === 0) { + console.log(`No messages in "${teamName}" channel.`); + return; + } + + console.log(''); + console.log(`CHAT: ${teamName}`); + console.log('-'.repeat(60)); + + for (const msg of messages) { + const time = new Date(msg.timestamp).toLocaleTimeString(); + console.log(` [${time}] ${msg.sender}: ${msg.body}`); + } + console.log(''); +} + +// ============================================================================ +// Command Registration +// ============================================================================ + export function registerSendInboxCommands(program: Command): void { - // genie send + // ── genie send ── program .command('send <body>') .description('Send a message to an agent') - .option('--to <agent>', 'Recipient agent ID (default: team-lead)', 'team-lead') + .option('--to <agent>', 'Recipient agent name (default: team-lead)', 'team-lead') .option('--from <sender>', 'Sender ID (auto-detected from context)') - .option('--team <team>', 'Team name (default: genie)', 'genie') - .action(async (body: string, options: { to: string; from?: string; team: string }) => { + .action(async (body: string, options: { to: string; from?: string }) => { try { const protocolRouter = await getProtocolRouter(); const repoPath = process.cwd(); - const from = options.from ?? (await detectSenderIdentity(options.team)); - const result = await protocolRouter.sendMessage(repoPath, from, options.to, body, options.team); + const from = options.from ?? (await detectSenderIdentity()); + + // Scope check: sender in a team → recipient must be in same team + const scopeError = await checkSendScope(repoPath, from, options.to); + if (scopeError) { + console.error(`Error: ${scopeError}`); + process.exit(1); + } + + const result = await protocolRouter.sendMessage(repoPath, from, options.to, body); if (result.delivered) { console.log(`Message sent to "${result.workerId}".`); @@ -127,17 +242,63 @@ export function registerSendInboxCommands(program: Command): void { } }); - // genie inbox + // ── genie broadcast ── + program + .command('broadcast <body>') + .description('Send a message to all members of your team (one-way)') + .option('--from <sender>', 'Sender ID (auto-detected from context)') + .action(async (body: string, options: { from?: string }) => { + try { + const protocolRouter = await getProtocolRouter(); + const repoPath = process.cwd(); + const from = options.from ?? (await detectSenderIdentity()); + + const team = await findAgentTeam(repoPath, from); + if (!team) { + console.error(`Error: Could not find team for sender "${from}".`); + process.exit(1); + } + + const recipients = team.members.filter((m) => m !== from); + if (recipients.length === 0) { + console.log('No team members to broadcast to.'); + return; + } + + let delivered = 0; + let failed = 0; + + for (const recipient of recipients) { + const result = await protocolRouter.sendMessage(repoPath, from, recipient, body); + if (result.delivered) { + delivered++; + } else { + failed++; + console.error(` Failed to deliver to "${recipient}": ${result.reason}`); + } + } + + console.log(`Broadcast complete: ${delivered} delivered, ${failed} failed (${recipients.length} total).`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); + + // ── genie inbox ── program - .command('inbox <agent>') - .description('View message inbox for an agent') + .command('inbox [agent]') + .description('View message inbox for an agent (auto-detected if omitted)') .option('--json', 'Output as JSON') .option('--unread', 'Show only unread messages') - .action(async (agent: string, options: { json?: boolean; unread?: boolean }) => { + .action(async (agent: string | undefined, options: { json?: boolean; unread?: boolean }) => { try { const protocolRouter = await getProtocolRouter(); const repoPath = process.cwd(); - let messages = await protocolRouter.getInbox(repoPath, agent); + const resolvedAgent = agent ?? (await detectSenderIdentity()); + + let messages = await protocolRouter.getInbox(repoPath, resolvedAgent); if (options.unread) { messages = messages.filter((m) => !m.read); @@ -148,7 +309,61 @@ export function registerSendInboxCommands(program: Command): void { return; } - printInbox(agent, messages, options.unread); + printInbox(resolvedAgent, messages, options.unread); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); + } + }); + + // ── genie chat ── + program + .command('chat [args...]') + .description('Team chat: "genie chat <msg>" to post, "genie chat read" to read history') + .option('--team <name>', 'Team name (auto-detected from context)') + .option('--since <timestamp>', 'Show messages since timestamp (read mode)') + .option('--json', 'Output as JSON') + .option('--from <sender>', 'Sender ID (auto-detected from context)') + .action(async (args: string[], options: { team?: string; since?: string; json?: boolean; from?: string }) => { + try { + const repoPath = process.cwd(); + const from = options.from ?? (await detectSenderIdentity()); + + // Determine team name + let teamName = options.team; + if (!teamName) { + const team = await findAgentTeam(repoPath, from); + if (team) { + teamName = team.name; + } else { + teamName = process.env.GENIE_TEAM; + } + if (!teamName) { + console.error('Error: Could not auto-detect team. Use --team <name>.'); + process.exit(1); + } + } + + const teamChat = await getTeamChat(); + + if (args.length === 0 || args[0] === 'read') { + // Read mode + const messages = await teamChat.readMessages(repoPath, teamName, options.since); + + if (options.json) { + console.log(JSON.stringify(messages, null, 2)); + return; + } + + printChatMessages(teamName, messages); + } else { + // Post mode + const body = args.join(' '); + const msg = await teamChat.postMessage(repoPath, teamName, from, body); + console.log(`Posted to "${teamName}" channel.`); + console.log(` ID: ${msg.id}`); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); diff --git a/src/term-commands/ship.ts b/src/term-commands/ship.ts deleted file mode 100644 index 6f290a946..000000000 --- a/src/term-commands/ship.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Ship command - Mark task as done and cleanup worker - * - * Usage: - * genie agent ship <task-id> - Mark done, cleanup worktree, kill agent - * - * Options: - * --keep-worktree - Don't remove the worktree - * --merge - Merge worktree changes to main branch - * -y, --yes - Skip confirmation - */ - -import { join } from 'node:path'; -import { confirm } from '@inquirer/prompts'; -import { $ } from 'bun'; -import * as registry from '../lib/agent-registry.js'; -import * as beadsRegistry from '../lib/beads-registry.js'; -import { getBackend } from '../lib/task-backend.js'; -import * as tmux from '../lib/tmux.js'; -import { cleanupEventFile } from './events.js'; - -// Use beads registry only when enabled AND bd exists on PATH -const useBeads = - beadsRegistry.isBeadsRegistryEnabled() && - (() => { - const BunExt = Bun as unknown as { which?: (name: string) => string | null }; - return typeof BunExt.which === 'function' ? Boolean(BunExt.which('bd')) : true; - })(); - -// ============================================================================ -// Types -// ============================================================================ - -export interface ShipOptions { - keepWorktree?: boolean; - merge?: boolean; - yes?: boolean; -} - -// ============================================================================ -// Configuration -// ============================================================================ - -// Worktrees are created inside the project at .genie/worktrees/<taskId> -const WORKTREE_DIR_NAME = '.genie/worktrees'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Get the current branch name - */ -async function getCurrentBranch(repoPath: string): Promise<string> { - const result = await $`git -C ${repoPath} branch --show-current`.quiet(); - return result.stdout.toString().trim(); -} - -/** - * Check if current branch is main/master and exit if so - * Used to prevent accidental pushes to protected branches - */ -async function assertNotMainBranch(repoPath: string): Promise<void> { - const branch = await getCurrentBranch(repoPath); - if (branch === 'main' || branch === 'master') { - console.error('❌ Cannot push from main/master. Use a feature branch.'); - console.error(' Run: git checkout -b work/<wish-id>'); - process.exit(1); - } -} - -/** - * Merge worktree branch to main - */ -async function mergeToMain(repoPath: string, branchName: string): Promise<boolean> { - try { - // Get main branch name (could be main or master) - let mainBranch = 'main'; - try { - const result = await $`git -C ${repoPath} symbolic-ref refs/remotes/origin/HEAD`.quiet(); - const ref = result.stdout.toString().trim(); - mainBranch = ref.replace('refs/remotes/origin/', ''); - } catch { - // Default to main if we can't detect - } - - // Get current branch - const currentResult = await $`git -C ${repoPath} branch --show-current`.quiet(); - const currentBranch = currentResult.stdout.toString().trim(); - - if (currentBranch === branchName) { - // We're on the worktree branch, need to switch to main first - console.log(` Switching to ${mainBranch}...`); - await $`git -C ${repoPath} checkout ${mainBranch}`.quiet(); - } - - console.log(` Merging ${branchName} into ${mainBranch}...`); - await $`git -C ${repoPath} merge ${branchName} --no-edit`.quiet(); - - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(` Merge failed: ${message}`); - return false; - } -} - -/** - * Remove worktree - */ -async function removeWorktree(taskId: string, repoPath: string): Promise<boolean> { - const worktreePath = join(repoPath, WORKTREE_DIR_NAME, taskId); - - try { - await $`git -C ${repoPath} worktree remove ${worktreePath} --force`.quiet(); - return true; - } catch { - // Worktree may already be removed - return true; - } -} - -/** - * Kill worker pane - */ -async function killWorkerPane(paneId: string): Promise<boolean> { - try { - await tmux.killPane(paneId); - return true; - } catch { - return false; // Pane may already be gone - } -} - -// ============================================================================ -// Main Command -// ============================================================================ - -/** - * Kill and unregister a worker, cleaning up tmux and registries. - */ -async function cleanupWorker(worker: registry.Agent): Promise<void> { - if (worker.windowName) { - console.log(`💀 Killing agent window "${worker.windowName}"...`); - try { - await tmux.killWindow(worker.windowName); - console.log(' ✅ Window killed'); - } catch { - console.log(' ℹ️ Window already gone'); - } - } else { - console.log('💀 Killing agent pane...'); - await killWorkerPane(worker.paneId); - console.log(' ✅ Pane killed'); - } - - if (useBeads) { - try { - await beadsRegistry.unbindWork(worker.id); - await beadsRegistry.setAgentState(worker.id, 'done'); - await beadsRegistry.deleteAgent(worker.id); - } catch { - /* Non-fatal */ - } - } - await registry.unregister(worker.id); - await cleanupEventFile(worker.paneId).catch(() => {}); - console.log(' ✅ Agent unregistered'); -} - -/** - * Find the worker for a task across registries. - */ -async function findWorkerForTask(taskId: string): Promise<registry.Agent | null> { - if (useBeads) { - const w = await beadsRegistry.findByTask(taskId); - if (w) return w; - } - return registry.findByTask(taskId); -} - -/** - * Build ship confirmation message. - */ -function buildShipMessage(taskId: string, title: string, worker: registry.Agent | null, merge: boolean): string { - const mergeNote = merge ? ', merge to main' : ''; - if (worker) return `Ship ${taskId} "${title}"? (mark done, kill agent pane ${worker.paneId}${mergeNote})`; - return `Ship ${taskId} "${title}"? (mark done${mergeNote})`; -} - -/** - * Handle worktree operations for ship (merge + remove). - */ -async function handleShipWorktree(worker: registry.Agent, taskId: string, options: ShipOptions): Promise<void> { - if (!worker.worktree || options.keepWorktree) return; - if (options.merge) { - console.log('🔀 Merging changes...'); - const merged = await mergeToMain(worker.repoPath, `work/${taskId}`); - if (merged) console.log(' ✅ Merged to main'); - } - console.log('🌳 Removing worktree...'); - const removed = await removeWorktree(taskId, worker.repoPath); - if (removed) console.log(' ✅ Worktree removed'); -} - -export async function shipCommand(taskId: string, options: ShipOptions = {}): Promise<void> { - try { - const repoPath = process.cwd(); - const backend = getBackend(repoPath); - await assertNotMainBranch(repoPath); - - const worker = await findWorkerForTask(taskId); - const task = await backend.get(taskId); - if (!task) { - console.error(`❌ Task "${taskId}" not found.`); - console.error(backend.kind === 'local' ? ' Check .genie/tasks.json' : ` Run \`bd show ${taskId}\` to check.`); - process.exit(1); - } - - if (!options.yes) { - const confirmed = await confirm({ - message: buildShipMessage(taskId, task.title, worker, !!options.merge), - default: true, - }); - if (!confirmed) { - console.log('Cancelled.'); - return; - } - } - - console.log(`📦 Marking ${taskId} as done...`); - console.log( - (await backend.markDone(taskId)) ? ' ✅ Task marked as done' : ` Failed to mark ${taskId} as done.`, - ); - - if (worker) { - await handleShipWorktree(worker, taskId, options); - await cleanupWorker(worker); - } - - console.log(`\n🚀 ${taskId} shipped successfully!`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} diff --git a/src/term-commands/state.ts b/src/term-commands/state.ts new file mode 100644 index 000000000..176298d48 --- /dev/null +++ b/src/term-commands/state.ts @@ -0,0 +1,161 @@ +/** + * State commands — CLI interface for wish state machine. + * + * Commands: + * genie done <slug>#<group> - Mark group as done, unblock dependents + * genie status <slug> - Pretty-print wish state overview + */ + +import type { Command } from 'commander'; +import * as wishState from '../lib/wish-state.js'; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Parse a `slug#group` reference. + * Examples: "auth-bug#2" → { slug: "auth-bug", group: "2" } + */ +export function parseRef(ref: string): { slug: string; group: string } { + const hashIdx = ref.indexOf('#'); + if (hashIdx === -1) { + throw new Error(`Invalid reference "${ref}". Expected format: <slug>#<group>`); + } + const slug = ref.slice(0, hashIdx); + const group = ref.slice(hashIdx + 1); + if (!slug || !group) { + throw new Error(`Invalid reference "${ref}". Both slug and group are required.`); + } + return { slug, group }; +} + +// ============================================================================ +// Status formatting +// ============================================================================ + +const STATUS_ICONS: Record<string, string> = { + blocked: '🔒', + ready: '🟢', + in_progress: '🔄', + done: '✅', +}; + +function formatTimestamp(iso?: string): string { + if (!iso) return ''; + const d = new Date(iso); + return d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +function padRight(str: string, len: number): string { + return str.length >= len ? str : str + ' '.repeat(len - str.length); +} + +// ============================================================================ +// Commands +// ============================================================================ + +/** + * `genie done <slug>#<group>` — complete a group and unblock dependents. + */ +export async function doneCommand(ref: string): Promise<void> { + try { + const { slug, group } = parseRef(ref); + const result = await wishState.completeGroup(slug, group); + console.log(`✅ Group "${group}" marked as done in wish "${slug}"`); + + if (result.completedAt) { + console.log(` Completed at: ${formatTimestamp(result.completedAt)}`); + } + + // Show which groups were unblocked + const state = await wishState.getState(slug); + if (state) { + const nowReady = Object.entries(state.groups) + .filter(([, g]) => g.status === 'ready' && g.dependsOn.includes(group)) + .map(([name]) => name); + + if (nowReady.length > 0) { + console.log(` Unblocked: ${nowReady.join(', ')}`); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ ${message}`); + process.exit(1); + } +} + +/** + * `genie status <slug>` — pretty-print all groups with state/assignee/timestamps. + */ +export async function statusCommand(slug: string): Promise<void> { + try { + const state = await wishState.getState(slug); + if (!state) { + console.error(`❌ No state found for wish "${slug}"`); + console.error(' State file expected at: .genie/state/<slug>.json'); + process.exit(1); + } + + console.log(`\nWish: ${state.wish}`); + console.log('─'.repeat(60)); + + const entries = Object.entries(state.groups); + const maxNameLen = Math.max(...entries.map(([name]) => name.length), 5); + + console.log(` ${padRight('GROUP', maxNameLen)} STATUS ASSIGNEE STARTED COMPLETED`); + console.log(` ${'─'.repeat(maxNameLen + 62)}`); + + for (const [name, group] of entries) { + const icon = STATUS_ICONS[group.status] ?? '❓'; + const status = padRight(`${icon} ${group.status}`, 13); + const assignee = padRight(group.assignee ?? '-', 13); + const started = padRight(formatTimestamp(group.startedAt) || '-', 14); + const completed = formatTimestamp(group.completedAt) || '-'; + + console.log(` ${padRight(name, maxNameLen)} ${status} ${assignee} ${started} ${completed}`); + } + + // Summary + const total = entries.length; + const done = entries.filter(([, g]) => g.status === 'done').length; + const inProgress = entries.filter(([, g]) => g.status === 'in_progress').length; + const ready = entries.filter(([, g]) => g.status === 'ready').length; + const blocked = entries.filter(([, g]) => g.status === 'blocked').length; + + console.log(''); + console.log(` Progress: ${done}/${total} done | ${inProgress} in progress | ${ready} ready | ${blocked} blocked`); + console.log(''); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ ${message}`); + process.exit(1); + } +} + +// ============================================================================ +// Registration +// ============================================================================ + +export function registerStateCommands(program: Command): void { + program + .command('done <ref>') + .description('Mark a wish group as done (format: <slug>#<group>)') + .action(async (ref: string) => { + await doneCommand(ref); + }); + + program + .command('status <slug>') + .description('Show wish state overview for all groups') + .action(async (slug: string) => { + await statusCommand(slug); + }); +} diff --git a/src/term-commands/task/commands.ts b/src/term-commands/task/commands.ts deleted file mode 100644 index f9e8b5884..000000000 --- a/src/term-commands/task/commands.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Task Namespace — Beads issue management + dependency-aware task management. - * - * Groups all task/issue management commands under `genie task`. - * - * Commands (beads integration - main): - * task create <title> - Create new beads issue - * task update <id> - Update task properties - * task ship <id> - Mark done + merge + cleanup - * task close <id> - Close + cleanup - * task ls - List ready tasks (= bd ready) - * task link - Link task to wish - * task unlink - Unlink task from wish - * - * Commands (dependency-aware - teams): - * task create-local <title> - Create a local dependency-tracked task - * task list-local - List local tasks with ready/blocked - * task update-local <id> - Update local task properties - * - * Group F: Tasks differentiate "ready" vs "blocked" based on - * dependency resolution. - */ - -import { exec } from 'node:child_process'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import type { Command } from 'commander'; -import { computePriorityScore, listTasks } from '../../lib/local-tasks.js'; -import { getBackend } from '../../lib/task-backend.js'; -import * as closeCmd from '../close.js'; -import * as createCmd from '../create.js'; -import * as shipCmd from '../ship.js'; -import * as updateCmd from '../update.js'; - -const execAsync = promisify(exec); - -// ============================================================================ -// Types (dependency-aware tasks from genie-cli-teams) -// ============================================================================ - -type TaskStatus = 'ready' | 'in_progress' | 'done' | 'blocked'; - -interface Task { - id: string; - title: string; - description?: string; - status: TaskStatus; - blockedBy: string[]; - createdAt: string; - updatedAt: string; -} - -interface TasksFile { - tasks: Record<string, Task>; - order: string[]; - nextId: number; - lastUpdated: string; -} - -// ============================================================================ -// Persistence (dependency-aware tasks) -// ============================================================================ - -function tasksFilePath(repoPath: string): string { - return join(repoPath, '.genie', 'tasks.json'); -} - -async function loadTasks(repoPath: string): Promise<TasksFile> { - try { - const content = await readFile(tasksFilePath(repoPath), 'utf-8'); - return JSON.parse(content); - } catch { - return { tasks: {}, order: [], nextId: 1, lastUpdated: new Date().toISOString() }; - } -} - -async function saveTasks(repoPath: string, data: TasksFile): Promise<void> { - await mkdir(join(repoPath, '.genie'), { recursive: true }); - data.lastUpdated = new Date().toISOString(); - await writeFile(tasksFilePath(repoPath), JSON.stringify(data, null, 2)); -} - -// ============================================================================ -// Task Logic (dependency resolution) -// ============================================================================ - -function resolveStatus(task: Task, allTasks: Record<string, Task>): TaskStatus { - if (task.status === 'done' || task.status === 'in_progress') return task.status; - if (task.blockedBy.length === 0) return 'ready'; - const allDone = task.blockedBy.every((id) => allTasks[id]?.status === 'done'); - return allDone ? 'ready' : 'blocked'; -} - -// ============================================================================ -function applyTaskUpdates(task: Task, options: { status?: string; title?: string; blockedBy?: string }): void { - if (options.status) task.status = options.status as TaskStatus; - if (options.title) task.title = options.title; - if (options.blockedBy !== undefined) { - task.blockedBy = options.blockedBy ? options.blockedBy.split(',').map((s) => s.trim()) : []; - } - task.updatedAt = new Date().toISOString(); -} - -// Local task group display -// ============================================================================ - -interface TaskWithEffectiveStatus extends Task { - effectiveStatus: string; -} - -function printTaskGroup( - label: string, - tasks: TaskWithEffectiveStatus[], - formatLine?: (t: TaskWithEffectiveStatus) => string, -): void { - if (tasks.length === 0) return; - console.log(`\n${label}:`); - const fmt = formatLine || ((t) => ` ${t.id}: ${t.title}`); - for (const t of tasks) { - console.log(fmt(t)); - } -} - -function printLocalTaskGroups(tasks: TaskWithEffectiveStatus[], showDone?: boolean): void { - console.log(''); - console.log('TASKS'); - console.log('='.repeat(60)); - - printTaskGroup( - 'In Progress', - tasks.filter((t) => t.effectiveStatus === 'in_progress'), - ); - printTaskGroup( - 'Ready', - tasks.filter((t) => t.effectiveStatus === 'ready'), - ); - printTaskGroup( - 'Blocked', - tasks.filter((t) => t.effectiveStatus === 'blocked'), - (t) => ` ${t.id}: ${t.title} (blocked by: ${t.blockedBy.join(', ')})`, - ); - if (showDone) { - printTaskGroup( - 'Done', - tasks.filter((t) => t.effectiveStatus === 'done'), - ); - } - console.log(''); -} - -// ============================================================================ -// Task display helpers -// ============================================================================ - -const STATUS_EMOJI: Record<string, string> = { - done: '✅', - in_progress: '🔄', - blocked: '🔴', -}; - -function formatTaskRow(task: ReturnType<typeof listTasks> extends Promise<(infer T)[]> ? T : never): string { - const id = task.id.padEnd(10).substring(0, 10); - const type = (task.issueType === 'epic' ? 'epic' : 'task').padEnd(4); - const emoji = STATUS_EMOJI[task.status] || '⚪'; - const status = `${emoji} ${task.status}`.padEnd(10).substring(0, 10); - const score = task.priorityScores ? computePriorityScore(task.priorityScores).toFixed(1).padStart(5) : ' — '; - const title = task.title.padEnd(48).substring(0, 48); - return `│ ${id} │ ${type} │ ${status} │ ${score} │ ${title} │`; -} - -async function displayLocalTasks(repoPath: string, options: { all?: boolean; json?: boolean }): Promise<void> { - const tasks = await listTasks(repoPath); - const filtered = options.all ? tasks : tasks.filter((t) => t.status !== 'done'); - - if (options.json) { - console.log(JSON.stringify(filtered, null, 2)); - return; - } - - if (filtered.length === 0) { - console.log('No tasks found. Use `genie task create "<title>"` to add one.'); - return; - } - - console.log(''); - console.log('┌────────────┬──────┬────────────┬───────┬──────────────────────────────────────────────────┐'); - console.log('│ ID │ Type │ Status │ Score │ Title │'); - console.log('├────────────┼──────┼────────────┼───────┼──────────────────────────────────────────────────┤'); - for (const task of filtered) { - console.log(formatTaskRow(task)); - } - console.log('└────────────┴──────┴────────────┴───────┴──────────────────────────────────────────────────┘'); - console.log(''); -} - -async function displayBeadsTasks(options: { all?: boolean }): Promise<void> { - try { - const cmd = options.all ? 'bd show --all' : 'bd ready'; - const { stdout } = await execAsync(cmd); - console.log(stdout); - } catch (error) { - const err = error as { stderr?: string }; - if (err.stderr) { - console.error(err.stderr); - } else { - console.error('Failed to list tasks. Make sure beads (bd) is installed.'); - } - process.exit(1); - } -} - -// ============================================================================ -// Register namespace -// ============================================================================ - -/** - * Register the `task` namespace with all subcommands - */ -export function registerTaskNamespace(program: Command): void { - const taskProgram = program.command('task').description('Task/issue management (beads + dependency-aware)'); - - // task create - taskProgram - .command('create <title>') - .description('Create a new beads issue') - .option('-d, --description <text>', 'Issue description') - .option('-p, --parent <id>', 'Parent issue ID (creates dependency)') - .option('--wish <slug>', 'Link to a wish document') - .option('--json', 'Output as JSON') - .action(async (title: string, options: createCmd.CreateOptions & { wish?: string }) => { - await createCmd.createCommand(title, options); - }); - - // task update - taskProgram - .command('update <task-id>') - .description('Update task properties (status, title, blocked-by)') - .option('--status <status>', 'New status (ready, in_progress, done, blocked)') - .option('--title <title>', 'New title') - .option('--blocked-by <ids>', 'Set blocked-by list (comma-separated task IDs)') - .option('--add-blocked-by <ids>', 'Add to blocked-by list (comma-separated task IDs)') - .option('--json', 'Output as JSON') - .action(async (taskId: string, options: updateCmd.UpdateOptions) => { - await updateCmd.updateCommand(taskId, options); - }); - - // task ship - taskProgram - .command('ship <task-id>') - .description('Mark task as done and cleanup worker') - .option('--keep-worktree', "Don't remove the worktree") - .option('--merge', 'Merge worktree changes to main branch') - .option('-y, --yes', 'Skip confirmation') - .action(async (taskId: string, options: shipCmd.ShipOptions) => { - await shipCmd.shipCommand(taskId, options); - }); - - // task close - taskProgram - .command('close <task-id>') - .description('Close task/issue and cleanup worker') - .option('--no-sync', 'Skip bd sync (beads only)') - .option('--keep-worktree', "Don't remove the worktree") - .option('--merge', 'Merge worktree changes to main branch') - .option('-y, --yes', 'Skip confirmation') - .action(async (taskId: string, options: closeCmd.CloseOptions) => { - await closeCmd.closeCommand(taskId, options); - }); - - // task ls (wrapper for bd ready) - taskProgram - .command('ls') - .alias('ready') - .description('List ready tasks (wrapper for bd ready)') - .option('--all', 'Show all tasks, not just ready') - .option('--json', 'Output as JSON') - .action(async (options: { all?: boolean; json?: boolean }) => { - const repoPath = process.cwd(); - const backend = getBackend(repoPath); - - if (backend.kind === 'local') { - await displayLocalTasks(repoPath, options); - } else { - await displayBeadsTasks(options); - } - }); - - // task link <wish> <task-id> - Link a task to a wish - taskProgram - .command('link <wish-slug> <task-id>') - .description('Link a beads task to a wish document') - .action(async (wishSlug: string, taskId: string) => { - const { linkTask, wishExists } = await import('../../lib/wish-tasks.js'); - const repoPath = process.cwd(); - - // Verify wish exists - if (!(await wishExists(repoPath, wishSlug))) { - console.error(`❌ Wish "${wishSlug}" not found in .genie/wishes/`); - process.exit(1); - } - - // Get task title from beads - let taskTitle = taskId; - try { - const { stdout } = await execAsync(`bd show ${taskId} --json 2>/dev/null`); - const issue = JSON.parse(stdout); - taskTitle = issue.title || taskId; - } catch { - // Use taskId as fallback title - } - - await linkTask(repoPath, wishSlug, taskId, taskTitle); - console.log(`✅ Linked ${taskId} → ${wishSlug}`); - }); - - // task unlink <wish> <task-id> - Unlink a task from a wish - taskProgram - .command('unlink <wish-slug> <task-id>') - .description('Unlink a beads task from a wish document') - .action(async (wishSlug: string, taskId: string) => { - const { unlinkTask } = await import('../../lib/wish-tasks.js'); - const repoPath = process.cwd(); - - const removed = await unlinkTask(repoPath, wishSlug, taskId); - if (removed) { - console.log(`✅ Unlinked ${taskId} from ${wishSlug}`); - } else { - console.log(`ℹ️ ${taskId} was not linked to ${wishSlug}`); - } - }); - - // ======================================================================== - // Dependency-aware local task commands (genie-cli-teams) - // ======================================================================== - - // task create-local - taskProgram - .command('create-local <title>') - .description('Create a new local dependency-tracked task') - .option('-d, --description <text>', 'Task description') - .option('--blocked-by <ids>', 'Comma-separated task IDs this depends on') - .action(async (title: string, options: { description?: string; blockedBy?: string }) => { - try { - const repoPath = process.cwd(); - const data = await loadTasks(repoPath); - const id = `task-${data.nextId}`; - data.nextId += 1; - - const blockedBy = options.blockedBy ? options.blockedBy.split(',').map((s) => s.trim()) : []; - - const now = new Date().toISOString(); - const newTask: Task = { - id, - title, - description: options.description, - status: blockedBy.length > 0 ? 'blocked' : 'ready', - blockedBy, - createdAt: now, - updatedAt: now, - }; - - data.tasks[id] = newTask; - data.order.push(id); - await saveTasks(repoPath, data); - - console.log(`Task created: ${id}`); - console.log(` Title: ${title}`); - console.log(` Status: ${newTask.status}`); - if (blockedBy.length > 0) { - console.log(` Blocked by: ${blockedBy.join(', ')}`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); - - // task list-local - taskProgram - .command('list-local') - .description('List local tasks with ready/blocked differentiation') - .option('--json', 'Output as JSON') - .option('--all', 'Include done tasks') - .action(async (options: { json?: boolean; all?: boolean }) => { - try { - const repoPath = process.cwd(); - const data = await loadTasks(repoPath); - const allTasks = data.tasks; - - const tasks = data.order - .map((id) => allTasks[id]) - .filter(Boolean) - .filter((t) => options.all || t.status !== 'done') - .map((t) => ({ - ...t, - effectiveStatus: resolveStatus(t, allTasks), - })); - - if (options.json) { - console.log(JSON.stringify(tasks, null, 2)); - return; - } - - if (tasks.length === 0) { - console.log('No tasks found. Create one: genie task create-local "My task"'); - return; - } - - printLocalTaskGroups(tasks, options.all); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); - - // task update-local - taskProgram - .command('update-local <id>') - .description('Update local task properties') - .option('--status <status>', 'New status: ready, in_progress, done, blocked') - .option('--title <title>', 'New title') - .option('--blocked-by <ids>', 'Set blocked-by list (comma-separated)') - .action(async (id: string, options: { status?: string; title?: string; blockedBy?: string }) => { - try { - const repoPath = process.cwd(); - const data = await loadTasks(repoPath); - const task = data.tasks[id]; - - if (!task) { - console.error(`Task "${id}" not found.`); - process.exit(1); - } - - applyTaskUpdates(task, options); - await saveTasks(repoPath, data); - - console.log(`Task "${id}" updated.`); - console.log(` Status: ${task.status}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } - }); -} diff --git a/src/term-commands/team.test.ts b/src/term-commands/team.test.ts new file mode 100644 index 000000000..c07393768 --- /dev/null +++ b/src/term-commands/team.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for Team CLI commands. + * Run with: bun test src/term-commands/team.test.ts + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { $ } from 'bun'; + +// ============================================================================ +// Test Setup +// ============================================================================ + +const TEST_DIR = '/tmp/team-cli-test'; +const TEST_REPO = join(TEST_DIR, 'test-repo'); + +// Path to the genie CLI entrypoint +const GENIE_BIN = join(import.meta.dir, '..', 'genie.ts'); + +async function setupTestRepo(): Promise<void> { + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore + } + + await mkdir(TEST_REPO, { recursive: true }); + await $`git -C ${TEST_REPO} init`.quiet(); + await $`git -C ${TEST_REPO} config user.email "test@test.com"`.quiet(); + await $`git -C ${TEST_REPO} config user.name "Test"`.quiet(); + + await writeFile(join(TEST_REPO, 'README.md'), '# Test Repo'); + await $`git -C ${TEST_REPO} add .`.quiet(); + await $`git -C ${TEST_REPO} commit -m "Initial commit"`.quiet(); + await $`git -C ${TEST_REPO} branch dev`.quiet(); +} + +async function cleanupTestRepo(): Promise<void> { + try { + const result = await $`git -C ${TEST_REPO} worktree list --porcelain`.quiet(); + const paths = result.stdout + .toString() + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice(9)) + .filter((path) => path !== TEST_REPO); + + for (const p of paths) { + try { + await $`git -C ${TEST_REPO} worktree remove ${p} --force`.quiet(); + } catch { + // Ignore + } + } + } catch { + // Ignore + } + + try { + await rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore + } +} + +/** Run genie CLI command and return stdout. */ +async function genie(...args: string[]): Promise<{ stdout: string; exitCode: number }> { + try { + const result = await $`bun ${GENIE_BIN} ${args}`.quiet().cwd(TEST_REPO); + return { stdout: result.stdout.toString(), exitCode: 0 }; + } catch (err: unknown) { + const shellErr = err as { stdout?: Buffer; exitCode?: number }; + return { + stdout: shellErr.stdout?.toString() ?? '', + exitCode: shellErr.exitCode ?? 1, + }; + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('genie team CLI', () => { + beforeAll(async () => { + await setupTestRepo(); + }); + + afterAll(async () => { + await cleanupTestRepo(); + }); + + test('team create creates a team', async () => { + const { stdout, exitCode } = await genie('team', 'create', 'feat/cli-test', '--repo', TEST_REPO, '--branch', 'dev'); + expect(exitCode).toBe(0); + expect(stdout).toContain('Team "feat/cli-test" created'); + expect(stdout).toContain('Worktree:'); + }); + + test('team create is idempotent', async () => { + const { exitCode } = await genie('team', 'create', 'feat/cli-test', '--repo', TEST_REPO, '--branch', 'dev'); + expect(exitCode).toBe(0); + }); + + test('team ls lists teams', async () => { + const { stdout, exitCode } = await genie('team', 'ls'); + expect(exitCode).toBe(0); + expect(stdout).toContain('feat/cli-test'); + }); + + test('team hire adds agent', async () => { + const { stdout, exitCode } = await genie('team', 'hire', 'implementor', '--team', 'feat/cli-test'); + expect(exitCode).toBe(0); + expect(stdout).toContain('Hired "implementor"'); + }); + + test('team hire council adds 10 members', async () => { + await genie('team', 'create', 'feat/council-cli', '--repo', TEST_REPO, '--branch', 'dev'); + const { stdout, exitCode } = await genie('team', 'hire', 'council', '--team', 'feat/council-cli'); + expect(exitCode).toBe(0); + expect(stdout).toContain('council members'); + }); + + test('team ls <name> lists members', async () => { + const { stdout, exitCode } = await genie('team', 'ls', 'feat/cli-test'); + expect(exitCode).toBe(0); + expect(stdout).toContain('implementor'); + }); + + test('team fire removes agent', async () => { + const { stdout, exitCode } = await genie('team', 'fire', 'implementor', '--team', 'feat/cli-test'); + expect(exitCode).toBe(0); + expect(stdout).toContain('Fired "implementor"'); + }); + + test('team disband removes team', async () => { + await genie('team', 'create', 'feat/disband-cli', '--repo', TEST_REPO, '--branch', 'dev'); + const { stdout, exitCode } = await genie('team', 'disband', 'feat/disband-cli'); + expect(exitCode).toBe(0); + expect(stdout).toContain('disbanded'); + }); + + test('ensure command does not exist', async () => { + const { exitCode } = await genie('team', 'ensure', 'test'); + expect(exitCode).not.toBe(0); + }); +}); diff --git a/src/term-commands/team.ts b/src/term-commands/team.ts index a5154e289..b076313ee 100644 --- a/src/term-commands/team.ts +++ b/src/term-commands/team.ts @@ -1,17 +1,16 @@ /** - * Team Namespace — CRUD for team lifecycle. + * Team Namespace — CRUD for team lifecycle with worktree management. * * Commands: - * genie team create <name> [--blueprint <bp>] - * genie team list - * genie team delete <name> - * genie team ensure <name> [--dir <path>] — auto-spawn team-lead if not running + * genie team create <name> --repo <path> [--branch dev] + * genie team hire <agent> [--team <name>] + * genie team fire <agent> [--team <name>] + * genie team ls [<name>] + * genie team disband <name> */ -import { homedir } from 'node:os'; -import { join } from 'node:path'; import type { Command } from 'commander'; -import { ensureTeamLead } from '../lib/team-auto-spawn.js'; +import type { TeamConfig } from '../lib/team-manager.js'; import * as teamManager from '../lib/team-manager.js'; export function registerTeamNamespace(program: Command): void { @@ -20,22 +19,17 @@ export function registerTeamNamespace(program: Command): void { // team create team .command('create <name>') - .description('Create a new team (optionally from a blueprint)') - .option('-b, --blueprint <name>', 'Blueprint to use (work, dream, review, fix, debug)') - .action(async (name: string, options: { blueprint?: string }) => { + .description('Create a new team with a git worktree') + .requiredOption('--repo <path>', 'Path to the git repository') + .option('--branch <branch>', 'Base branch to create from', 'dev') + .action(async (name: string, options: { repo: string; branch: string }) => { try { - const repoPath = process.cwd(); - const config = await teamManager.createTeam(repoPath, name, options.blueprint); + const config = await teamManager.createTeam(name, options.repo, options.branch); console.log(`Team "${config.name}" created.`); - if (config.blueprint) { - console.log(` Blueprint: ${config.blueprint}`); - } - if (config.roles.length > 0) { - console.log(` Roles: ${config.roles.map((r) => r.name).join(', ')}`); - } + console.log(` Worktree: ${config.worktreePath}`); + console.log(` Branch: ${config.name} (from ${config.baseBranch})`); if (config.nativeTeamsEnabled) { - console.log(' Native teams: enabled (CC detected)'); - console.log(` Session: ${config.nativeTeamParentSessionId ?? '(pending)'}`); + console.log(' Native teams: enabled'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -44,37 +38,31 @@ export function registerTeamNamespace(program: Command): void { } }); - // team list + // team hire team - .command('list') - .alias('ls') - .description('List all teams') - .option('--json', 'Output as JSON') - .action(async (options: { json?: boolean }) => { + .command('hire <agent>') + .description('Add an agent to a team ("council" hires all 10 council members)') + .option('--team <name>', 'Team name (auto-detects from leader context if omitted)') + .action(async (agent: string, options: { team?: string }) => { try { const repoPath = process.cwd(); - const teams = await teamManager.listTeams(repoPath); - - if (options.json) { - console.log(JSON.stringify(teams, null, 2)); - return; - } - - if (teams.length === 0) { - console.log('No teams found. Create one with: genie team create <name> --blueprint work'); - return; + const teamName = options.team ?? (await autoDetectTeam(repoPath)); + if (!teamName) { + console.error('Error: Could not detect team. Use --team <name> to specify.'); + process.exit(1); } - console.log(''); - console.log('TEAMS'); - console.log('-'.repeat(60)); - for (const t of teams) { - const roles = t.roles.map((r) => r.name).join(', ') || '(no roles)'; - const bp = t.blueprint ? ` [${t.blueprint}]` : ''; - console.log(` ${t.name}${bp}`); - console.log(` Roles: ${roles}`); + const added = await teamManager.hireAgent(teamName, agent, repoPath); + if (added.length === 0) { + console.log(`Agent "${agent}" is already a member of "${teamName}".`); + } else if (agent === 'council') { + console.log(`Hired ${added.length} council members to "${teamName}":`); + for (const name of added) { + console.log(` + ${name}`); + } + } else { + console.log(`Hired "${agent}" to team "${teamName}".`); } - console.log(''); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); @@ -82,19 +70,25 @@ export function registerTeamNamespace(program: Command): void { } }); - // team delete + // team fire team - .command('delete <name>') - .alias('rm') - .description('Delete a team') - .action(async (name: string) => { + .command('fire <agent>') + .description('Remove an agent from a team') + .option('--team <name>', 'Team name (auto-detects from leader context if omitted)') + .action(async (agent: string, options: { team?: string }) => { try { const repoPath = process.cwd(); - const deleted = await teamManager.deleteTeam(repoPath, name); - if (deleted) { - console.log(`Team "${name}" deleted.`); + const teamName = options.team ?? (await autoDetectTeam(repoPath)); + if (!teamName) { + console.error('Error: Could not detect team. Use --team <name> to specify.'); + process.exit(1); + } + + const removed = await teamManager.fireAgent(teamName, agent, repoPath); + if (removed) { + console.log(`Fired "${agent}" from team "${teamName}".`); } else { - console.error(`Team "${name}" not found.`); + console.error(`Agent "${agent}" is not a member of "${teamName}".`); process.exit(1); } } catch (error) { @@ -104,18 +98,19 @@ export function registerTeamNamespace(program: Command): void { } }); - // team ensure — auto-spawn team-lead if not running + // team ls — no arg = list teams, with arg = list members team - .command('ensure <name>') - .description('Ensure a team exists with an active Claude Code team-lead (non-interactive)') - .option('-d, --dir <path>', 'Working directory for the team-lead', join(homedir(), 'workspace')) - .action(async (name: string, options: { dir: string }) => { + .command('ls [name]') + .alias('list') + .description('List teams or members of a team') + .option('--json', 'Output as JSON') + .action(async (name: string | undefined, options: { json?: boolean }) => { try { - const result = await ensureTeamLead(name, options.dir); - if (result.created) { - console.log(`Team "${name}" auto-spawned in session "${result.session}", window "${result.window}"`); + const repoPath = process.cwd(); + if (name) { + await printMembers(repoPath, name, options.json); } else { - console.log(`Team "${name}" already active.`); + await printTeams(repoPath, options.json); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -124,19 +119,98 @@ export function registerTeamNamespace(program: Command): void { } }); - // team blueprints + // team disband team - .command('blueprints') - .description('List available blueprints') - .action(() => { - const names = teamManager.listBlueprints(); - console.log('Available blueprints:'); - for (const name of names) { - const bp = teamManager.getBlueprint(name); - if (bp) { - const roles = bp.roles.map((r) => r.name).join(', '); - console.log(` ${name}: ${roles}`); + .command('disband <name>') + .description('Disband a team: kill members, remove worktree, delete config') + .action(async (name: string) => { + try { + const repoPath = process.cwd(); + const disbanded = await teamManager.disbandTeam(repoPath, name); + if (disbanded) { + console.log(`Team "${name}" disbanded.`); + } else { + console.error(`Team "${name}" not found.`); + process.exit(1); } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exit(1); } }); } + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Auto-detect team name from GENIE_TEAM env var. */ +async function autoDetectTeam(repoPath: string): Promise<string | null> { + const envTeam = process.env.GENIE_TEAM; + if (envTeam) return envTeam; + + const teams = await teamManager.listTeams(repoPath); + if (teams.length === 1) return teams[0].name; + + return null; +} + +/** Print members of a specific team. */ +async function printMembers(repoPath: string, name: string, json?: boolean): Promise<void> { + const members = await teamManager.listMembers(repoPath, name); + if (members === null) { + console.error(`Team "${name}" not found.`); + process.exit(1); + } + + if (json) { + console.log(JSON.stringify(members, null, 2)); + return; + } + + if (members.length === 0) { + console.log(`Team "${name}" has no members. Hire agents with: genie team hire <agent> --team ${name}`); + return; + } + + console.log(''); + console.log(`MEMBERS of "${name}"`); + console.log('-'.repeat(60)); + for (const m of members) { + console.log(` ${m}`); + } + console.log(''); +} + +/** Print all teams. */ +async function printTeams(repoPath: string, json?: boolean): Promise<void> { + const teams = await teamManager.listTeams(repoPath); + + if (json) { + console.log(JSON.stringify(teams, null, 2)); + return; + } + + if (teams.length === 0) { + console.log('No teams found. Create one with: genie team create <name> --repo <path>'); + return; + } + + console.log(''); + console.log('TEAMS'); + console.log('-'.repeat(60)); + for (const t of teams) { + printTeamSummary(t); + } + console.log(''); +} + +/** Print a single team summary line. */ +function printTeamSummary(t: TeamConfig): void { + console.log(` ${t.name}`); + console.log(` Repo: ${t.repo}`); + console.log(` Branch: ${t.name} (from ${t.baseBranch})`); + console.log(` Worktree: ${t.worktreePath}`); + console.log(` Members: ${t.members.length}`); +} diff --git a/src/term-commands/update.ts b/src/term-commands/update.ts deleted file mode 100644 index 1cbf9fad3..000000000 --- a/src/term-commands/update.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Update command - Update task properties (status, title, blockedBy) - * - * Usage: - * genie task update <task-id> --status <status> - * genie task update <task-id> --title <title> - * genie task update <task-id> --blocked-by <id1,id2,...> - * genie task update <task-id> --add-blocked-by <id> - * - * Status values: ready, in_progress, done, blocked - */ - -import { type TaskSummary, getBackend } from '../lib/task-backend.js'; - -export interface UpdateOptions { - status?: string; - title?: string; - blockedBy?: string; - addBlockedBy?: string; - json?: boolean; -} - -const VALID_STATUSES = ['ready', 'in_progress', 'done', 'blocked']; - -function validateUpdateOptions(options: UpdateOptions): void { - if ( - options.status === undefined && - options.title === undefined && - options.blockedBy === undefined && - options.addBlockedBy === undefined - ) { - console.error('❌ No update options provided. Use --status, --title, --blocked-by, or --add-blocked-by'); - process.exit(1); - } - - if (options.status && !VALID_STATUSES.includes(options.status)) { - console.error(`❌ Invalid status "${options.status}". Valid values: ${VALID_STATUSES.join(', ')}`); - process.exit(1); - } -} - -function printUpdateSummary( - taskId: string, - existing: TaskSummary, - updated: TaskSummary, - options: UpdateOptions, - blockedBy?: string[], - addBlockedBy?: string[], -): void { - console.log(`✅ Updated ${taskId}`); - const changes: string[] = []; - if (options.status) changes.push(`status: ${existing.status} → ${updated.status}`); - if (options.title) changes.push(`title: "${existing.title}" → "${updated.title}"`); - if (blockedBy !== undefined) - changes.push(`blockedBy: [${(existing.blockedBy || []).join(', ')}] → [${(updated.blockedBy || []).join(', ')}]`); - if (addBlockedBy !== undefined) - changes.push(`blockedBy: added ${addBlockedBy.join(', ')} → [${(updated.blockedBy || []).join(', ')}]`); - for (const change of changes) console.log(` ${change}`); -} - -function parseCommaList(value: string | undefined): string[] | undefined { - return value !== undefined - ? value - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : undefined; -} - -export async function updateCommand(taskId: string, options: UpdateOptions): Promise<void> { - try { - validateUpdateOptions(options); - - const repoPath = process.cwd(); - const backend = getBackend(repoPath); - - const existing = await backend.get(taskId); - if (!existing) { - console.error(`❌ Task "${taskId}" not found.`); - console.error( - backend.kind === 'local' ? ' Check .genie/tasks.json' : ' Run `bd list` to see available tasks.', - ); - process.exit(1); - } - - const blockedBy = parseCommaList(options.blockedBy); - const addBlockedBy = parseCommaList(options.addBlockedBy); - - // Perform update - const updated = await backend.update(taskId, { - status: options.status, - title: options.title, - blockedBy, - addBlockedBy, - }); - - if (!updated) { - console.error(`❌ Failed to update task "${taskId}".`); - process.exit(1); - } - - if (options.json) { - console.log(JSON.stringify(updated, null, 2)); - return; - } - - printUpdateSummary(taskId, existing, updated, options, blockedBy, addBlockedBy); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} diff --git a/src/term-commands/work.test.ts b/src/term-commands/work.test.ts deleted file mode 100644 index 1ad5e4bac..000000000 --- a/src/term-commands/work.test.ts +++ /dev/null @@ -1,775 +0,0 @@ -/** - * Integration tests for genie work command - * - * Tests: - * 1. Nested repo detection (--repo flag, wish.md repo field, heuristics) - * 4. Wish file search in .wishes/ directory - * 2. Worktree creation in correct repository - * 3. Branch naming conventions (work/<wish-id>) - * 4. .env sourcing from root repo in worktrees - */ - -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; -import { access, mkdir, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { $ } from 'bun'; - -// ============================================================================ -// Test Helpers -// ============================================================================ - -/** - * Create a temporary git repository for testing - */ -async function createTempGitRepo(basePath: string, name: string): Promise<string> { - const repoPath = join(basePath, name); - await mkdir(repoPath, { recursive: true }); - await $`git -C ${repoPath} init`.quiet(); - await $`git -C ${repoPath} config user.email "test@test.com"`.quiet(); - await $`git -C ${repoPath} config user.name "Test User"`.quiet(); - - // Create initial commit - const readme = join(repoPath, 'README.md'); - await writeFile(readme, '# Test Repo'); - await $`git -C ${repoPath} add .`.quiet(); - await $`git -C ${repoPath} commit -m "Initial commit"`.quiet(); - - return repoPath; -} - -/** - * Create a wish.md file with optional repo field - */ -async function createWishFile( - repoPath: string, - wishId: string, - options: { repo?: string; title?: string } = {}, -): Promise<string> { - const wishDir = join(repoPath, '.genie', 'wishes', wishId); - await mkdir(wishDir, { recursive: true }); - - const content = `# Wish: ${options.title || 'Test Wish'} - -**Status:** IN-PROGRESS -**Slug:** \`${wishId}\` -${options.repo ? `**repo:** \`${options.repo}\`\n` : ''} -## Summary - -This is a test wish for integration testing. -`; - - const wishPath = join(wishDir, 'wish.md'); - await writeFile(wishPath, content); - return wishPath; -} - -/** - * Check if a path exists - */ -async function pathExists(path: string): Promise<boolean> { - try { - await access(path); - return true; - } catch { - return false; - } -} - -// ============================================================================ -// Import functions to test -// ============================================================================ - -// Import the internal detection function for unit testing -// We re-implement minimal versions for testing since the actual function is not exported - -import { isAbsolute, resolve } from 'node:path'; - -// Import the env source prefix builder for unit testing -import { buildEnvSourcePrefix } from './work.js'; - -const KNOWN_NESTED_REPOS: Record<string, string> = { - 'genie-cli': 'code/genie-cli', - 'term-cli': 'code/genie-cli', - 'genie work': 'code/genie-cli', - 'genie ship': 'code/genie-cli', - 'term push': 'code/genie-cli', -}; - -/** - * Parse wish.md metadata (repo and title fields). - */ -async function parseWishMetadata(wishPath: string): Promise<{ repo?: string; title?: string }> { - try { - const file = Bun.file(wishPath); - const content = await file.text(); - const lines = content.split('\n'); - - let repo: string | undefined; - for (const line of lines.slice(0, 20)) { - const repoMatch = line.match(/^\*\*repo:\*\*\s*`?([^`]+)`?$/i); - if (repoMatch) { - repo = repoMatch[1].trim(); - break; - } - } - - const titleMatch = lines[0]?.match(/^#\s+(?:Wish:\s+)?(.+)$/i); - const title = titleMatch ? titleMatch[1].trim() : undefined; - - return { repo, title }; - } catch { - return {}; - } -} - -/** - * Try to detect target repo via heuristic keyword matching. - */ -async function detectViaHeuristic( - searchText: string, - repoPath: string, -): Promise<{ targetRepo: string; detectionMethod: string } | null> { - const lowerText = searchText.toLowerCase(); - for (const [keyword, relativePath] of Object.entries(KNOWN_NESTED_REPOS)) { - if (!lowerText.includes(keyword.toLowerCase())) continue; - const fullPath = join(repoPath, relativePath); - if (await pathExists(join(fullPath, '.git'))) { - return { targetRepo: fullPath, detectionMethod: `heuristic (matched "${relativePath}")` }; - } - } - return null; -} - -/** - * Simulated repo detection logic (mirrors work.ts detectTargetRepo) - */ -async function detectTargetRepo( - taskId: string, - repoPath: string, - explicitRepo?: string, - issueTitle?: string, - issueDescription?: string, -): Promise<{ targetRepo: string; detectionMethod: string }> { - if (explicitRepo) { - const targetPath = isAbsolute(explicitRepo) ? explicitRepo : resolve(repoPath, explicitRepo); - return { targetRepo: targetPath, detectionMethod: '--repo flag' }; - } - - const wishPath = join(repoPath, '.genie', 'wishes', taskId, 'wish.md'); - const metadata = await parseWishMetadata(wishPath); - - if (metadata.repo) { - const targetPath = isAbsolute(metadata.repo) ? metadata.repo : resolve(repoPath, metadata.repo); - return { targetRepo: targetPath, detectionMethod: 'wish.md repo: field' }; - } - - const title = metadata.title || issueTitle || ''; - const description = issueDescription || ''; - const heuristic = await detectViaHeuristic(`${title} ${description}`, repoPath); - if (heuristic) return heuristic; - - return { targetRepo: repoPath, detectionMethod: 'default (current repo)' }; -} - -// ============================================================================ -// Tests -// ============================================================================ - -describe('genie work - nested repo detection', () => { - let tempDir: string; - let macroRepo: string; - let nestedRepo: string; - - beforeAll(async () => { - // Create temp directory - tempDir = join('/tmp', `work-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - - // Create macro repo (like guga) - macroRepo = await createTempGitRepo(tempDir, 'macro-repo'); - - // Create nested repo (like code/genie-cli) - const codeDir = join(macroRepo, 'code'); - await mkdir(codeDir, { recursive: true }); - nestedRepo = await createTempGitRepo(codeDir, 'genie-cli'); - - // Create .genie directory - await mkdir(join(macroRepo, '.genie'), { recursive: true }); - }); - - afterAll(async () => { - // Clean up temp directory - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('--repo flag detection', () => { - it('should use explicit --repo flag when provided (relative path)', async () => { - const result = await detectTargetRepo( - 'wish-1', - macroRepo, - 'code/genie-cli', // explicit --repo - 'Some wish title', - undefined, - ); - - expect(result.detectionMethod).toBe('--repo flag'); - expect(result.targetRepo).toBe(nestedRepo); - }); - - it('should use explicit --repo flag when provided (absolute path)', async () => { - const result = await detectTargetRepo( - 'wish-1', - macroRepo, - nestedRepo, // absolute path - undefined, - undefined, - ); - - expect(result.detectionMethod).toBe('--repo flag'); - expect(result.targetRepo).toBe(nestedRepo); - }); - - it('should prioritize --repo flag over wish.md repo field', async () => { - // Create wish with different repo field - await createWishFile(macroRepo, 'wish-2', { - repo: 'some/other/repo', - title: 'Test Wish', - }); - - const result = await detectTargetRepo( - 'wish-2', - macroRepo, - 'code/genie-cli', // --repo should win - undefined, - undefined, - ); - - expect(result.detectionMethod).toBe('--repo flag'); - expect(result.targetRepo).toBe(nestedRepo); - }); - }); - - describe('wish.md repo field detection', () => { - it('should detect repo from wish.md repo: field', async () => { - await createWishFile(macroRepo, 'wish-3', { - repo: 'code/genie-cli', - title: 'Update something', - }); - - const result = await detectTargetRepo( - 'wish-3', - macroRepo, - undefined, // no --repo flag - undefined, - undefined, - ); - - expect(result.detectionMethod).toBe('wish.md repo: field'); - expect(result.targetRepo).toBe(nestedRepo); - }); - - it('should handle absolute path in wish.md repo field', async () => { - await createWishFile(macroRepo, 'wish-4', { - repo: nestedRepo, // absolute path - title: 'Test', - }); - - const result = await detectTargetRepo('wish-4', macroRepo, undefined, undefined, undefined); - - expect(result.detectionMethod).toBe('wish.md repo: field'); - expect(result.targetRepo).toBe(nestedRepo); - }); - }); - - describe('heuristic detection', () => { - it('should detect genie-cli from wish title containing "genie-cli"', async () => { - await createWishFile(macroRepo, 'wish-5', { - title: 'Fix bug in genie-cli', - }); - - const result = await detectTargetRepo('wish-5', macroRepo, undefined, 'Fix bug in genie-cli', undefined); - - expect(result.detectionMethod).toBe('heuristic (matched "code/genie-cli")'); - expect(result.targetRepo).toBe(nestedRepo); - }); - - it('should detect genie-cli from wish title containing "genie work"', async () => { - await createWishFile(macroRepo, 'wish-6', { - title: 'Improve genie work command', - }); - - const result = await detectTargetRepo('wish-6', macroRepo, undefined, 'Improve genie work command', undefined); - - expect(result.detectionMethod).toBe('heuristic (matched "code/genie-cli")'); - expect(result.targetRepo).toBe(nestedRepo); - }); - - it('should not match heuristic if nested repo does not exist', async () => { - // Create wish mentioning non-existent repo - await createWishFile(macroRepo, 'wish-7', { - title: 'Fix something in nonexistent-repo', - }); - - const result = await detectTargetRepo( - 'wish-7', - macroRepo, - undefined, - 'Fix something in nonexistent-repo', - undefined, - ); - - // Should fall back to default since nonexistent-repo doesn't exist - expect(result.detectionMethod).toBe('default (current repo)'); - expect(result.targetRepo).toBe(macroRepo); - }); - - it('should use description for heuristic matching too', async () => { - await createWishFile(macroRepo, 'wish-8', { - title: 'Generic title', - }); - - const result = await detectTargetRepo( - 'wish-8', - macroRepo, - undefined, - 'Generic title', - 'This updates the genie-cli package', // keyword in description - ); - - expect(result.detectionMethod).toBe('heuristic (matched "code/genie-cli")'); - expect(result.targetRepo).toBe(nestedRepo); - }); - }); - - describe('default fallback', () => { - it('should fall back to current repo when no match found', async () => { - await createWishFile(macroRepo, 'wish-9', { - title: 'Update documentation', - }); - - const result = await detectTargetRepo( - 'wish-9', - macroRepo, - undefined, - 'Update documentation', - 'Just some doc changes', - ); - - expect(result.detectionMethod).toBe('default (current repo)'); - expect(result.targetRepo).toBe(macroRepo); - }); - - it('should fall back when wish.md does not exist', async () => { - // Don't create wish file for wish-10 - const result = await detectTargetRepo('wish-10', macroRepo, undefined, undefined, undefined); - - expect(result.detectionMethod).toBe('default (current repo)'); - expect(result.targetRepo).toBe(macroRepo); - }); - }); -}); - -describe('genie work - worktree creation', () => { - let tempDir: string; - let testRepo: string; - - beforeEach(async () => { - // Fresh temp dir for each test - tempDir = join('/tmp', `worktree-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - testRepo = await createTempGitRepo(tempDir, 'test-repo'); - await mkdir(join(testRepo, '.genie'), { recursive: true }); - }); - - afterEach(async () => { - // Clean up worktrees first (git requires this) - try { - await $`git -C ${testRepo} worktree prune`.quiet(); - } catch { - /* ignore */ - } - - await rm(tempDir, { recursive: true, force: true }); - }); - - it('should create worktree in .genie/worktrees/<wish-id>/', async () => { - const wishId = 'wish-test-1'; - const worktreePath = join(testRepo, '.genie', 'worktrees', wishId); - const branchName = `work/${wishId}`; - - // Create worktree using git directly (simulating what work.ts does) - await mkdir(join(testRepo, '.genie', 'worktrees'), { recursive: true }); - await $`git -C ${testRepo} worktree add -b ${branchName} ${worktreePath}`.quiet(); - - // Verify worktree was created - expect(await pathExists(worktreePath)).toBe(true); - expect(await pathExists(join(worktreePath, '.git'))).toBe(true); - - // Verify branch name - const result = await $`git -C ${worktreePath} branch --show-current`.quiet(); - expect(result.stdout.toString().trim()).toBe(branchName); - }); - - it('should create worktree with existing branch if it already exists', async () => { - const wishId = 'wish-existing-branch'; - const worktreePath = join(testRepo, '.genie', 'worktrees', wishId); - const branchName = `work/${wishId}`; - - // Create branch first - await $`git -C ${testRepo} branch ${branchName}`.quiet(); - - // Now create worktree with existing branch - await mkdir(join(testRepo, '.genie', 'worktrees'), { recursive: true }); - await $`git -C ${testRepo} worktree add ${worktreePath} ${branchName}`.quiet(); - - // Verify worktree was created - expect(await pathExists(worktreePath)).toBe(true); - - // Verify branch name - const result = await $`git -C ${worktreePath} branch --show-current`.quiet(); - expect(result.stdout.toString().trim()).toBe(branchName); - }); - - it('should create .genie/redirect file in worktree', async () => { - const wishId = 'wish-redirect'; - const worktreePath = join(testRepo, '.genie', 'worktrees', wishId); - const branchName = `work/${wishId}`; - - // Create worktree - await mkdir(join(testRepo, '.genie', 'worktrees'), { recursive: true }); - await $`git -C ${testRepo} worktree add -b ${branchName} ${worktreePath}`.quiet(); - - // Create .genie/redirect (as work.ts does via worktree-manager) - const genieDir = join(worktreePath, '.genie'); - await mkdir(genieDir, { recursive: true }); - const redirectPath = join(genieDir, 'redirect'); - await writeFile(redirectPath, join(testRepo, '.genie')); - - // Verify redirect file exists and has correct content - expect(await pathExists(redirectPath)).toBe(true); - const content = await Bun.file(redirectPath).text(); - expect(content).toBe(join(testRepo, '.genie')); - }); -}); - -describe('genie work - branch naming convention', () => { - let tempDir: string; - let testRepo: string; - - beforeEach(async () => { - tempDir = join('/tmp', `branch-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - testRepo = await createTempGitRepo(tempDir, 'test-repo'); - }); - - afterEach(async () => { - try { - await $`git -C ${testRepo} worktree prune`.quiet(); - } catch { - /* ignore */ - } - await rm(tempDir, { recursive: true, force: true }); - }); - - it('should name branch work/<wish-id>', async () => { - const testCases = ['wish-1', 'wish-22', 'wish-long-name-123', 'some-task-id']; - - for (const wishId of testCases) { - const expectedBranch = `work/${wishId}`; - const worktreePath = join(testRepo, '.genie', 'worktrees', wishId); - - await mkdir(join(testRepo, '.genie', 'worktrees'), { recursive: true }); - await $`git -C ${testRepo} worktree add -b ${expectedBranch} ${worktreePath}`.quiet(); - - const result = await $`git -C ${worktreePath} branch --show-current`.quiet(); - expect(result.stdout.toString().trim()).toBe(expectedBranch); - - // Cleanup for next iteration - await $`git -C ${testRepo} worktree remove ${worktreePath} --force`.quiet(); - } - }); -}); - -// ============================================================================ -// Wish file search in .wishes/ directory -// ============================================================================ - -import { findWishInDotWishes, loadWishContent, wishFileExists } from './work.js'; - -describe('genie work - wish file search in .wishes/ directory', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = join('/tmp', `wish-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(tempDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('wishFileExists()', () => { - it('should return true when wish.md exists in .genie/wishes/<taskId>/ (fast path)', async () => { - const taskId = 'bd-42'; - const wishDir = join(tempDir, '.genie', 'wishes', taskId); - await mkdir(wishDir, { recursive: true }); - await writeFile(join(wishDir, 'wish.md'), '# Test wish'); - - expect(await wishFileExists(taskId, tempDir)).toBe(true); - }); - - it('should return true when a *-wish.md file in .wishes/ references the taskId via **Beads:**', async () => { - const taskId = 'bd-99'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile( - join(wishesDir, 'my-feature-wish.md'), - `# My Feature Wish - -**Beads:** ${taskId} - -## Summary - -Do something cool. -`, - ); - - expect(await wishFileExists(taskId, tempDir)).toBe(true); - }); - - it('should return true when a *-wish.md file in .wishes/ references the taskId via Beads:', async () => { - const taskId = 'bd-77'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile( - join(wishesDir, 'another-wish.md'), - `# Another Wish - -Beads: ${taskId} - -## Summary - -Do something else. -`, - ); - - expect(await wishFileExists(taskId, tempDir)).toBe(true); - }); - - it('should return true when *-wish.md is in a nested subdirectory of .wishes/', async () => { - const taskId = 'bd-123'; - const nestedDir = join(tempDir, '.wishes', 'features', 'phase-1'); - await mkdir(nestedDir, { recursive: true }); - await writeFile( - join(nestedDir, 'deep-feature-wish.md'), - `# Deep Feature - -**Beads:** ${taskId} -`, - ); - - expect(await wishFileExists(taskId, tempDir)).toBe(true); - }); - - it('should return false when no wish file exists anywhere', async () => { - expect(await wishFileExists('bd-nonexistent', tempDir)).toBe(false); - }); - - it('should return false when .wishes/ dir does not exist', async () => { - // tempDir has no .wishes/ or .genie/ dirs - expect(await wishFileExists('bd-1', tempDir)).toBe(false); - }); - - it('should return false when .wishes/ files exist but none reference the taskId', async () => { - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile( - join(wishesDir, 'unrelated-wish.md'), - `# Unrelated Wish - -**Beads:** bd-other-id - -## Summary - -Not the one we're looking for. -`, - ); - - expect(await wishFileExists('bd-999', tempDir)).toBe(false); - }); - - it('should prefer .genie/wishes/ fast path over .wishes/ search', async () => { - const taskId = 'bd-50'; - // Create in both locations - const genieWishDir = join(tempDir, '.genie', 'wishes', taskId); - await mkdir(genieWishDir, { recursive: true }); - await writeFile(join(genieWishDir, 'wish.md'), '# Genie wish'); - - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile(join(wishesDir, 'alt-wish.md'), `# Alt wish\n**Beads:** ${taskId}`); - - // Should still return true (fast path hits) - expect(await wishFileExists(taskId, tempDir)).toBe(true); - }); - - it('should not match files that do not end with -wish.md', async () => { - const taskId = 'bd-55'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - // File named notes.md (not *-wish.md) - await writeFile(join(wishesDir, 'notes.md'), `# Notes\n**Beads:** ${taskId}`); - - expect(await wishFileExists(taskId, tempDir)).toBe(false); - }); - }); - - describe('loadWishContent()', () => { - it('should load content from .genie/wishes/<taskId>/wish.md (fast path)', async () => { - const taskId = 'bd-42'; - const wishDir = join(tempDir, '.genie', 'wishes', taskId); - await mkdir(wishDir, { recursive: true }); - const content = '# Test wish\n\nSome content here.'; - await writeFile(join(wishDir, 'wish.md'), content); - - expect(await loadWishContent(taskId, tempDir)).toBe(content); - }); - - it('should load content from .wishes/ when not in .genie/wishes/', async () => { - const taskId = 'bd-88'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - const content = `# Feature Wish\n\n**Beads:** ${taskId}\n\n## Details\n\nBuild something.`; - await writeFile(join(wishesDir, 'feature-wish.md'), content); - - expect(await loadWishContent(taskId, tempDir)).toBe(content); - }); - - it('should return undefined when no wish file exists anywhere', async () => { - expect(await loadWishContent('bd-nonexistent', tempDir)).toBeUndefined(); - }); - - it('should prefer .genie/wishes/ content over .wishes/ content', async () => { - const taskId = 'bd-60'; - const genieContent = '# Genie wish content'; - const genieWishDir = join(tempDir, '.genie', 'wishes', taskId); - await mkdir(genieWishDir, { recursive: true }); - await writeFile(join(genieWishDir, 'wish.md'), genieContent); - - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile(join(wishesDir, 'alt-wish.md'), `# Alt wish\n**Beads:** ${taskId}`); - - expect(await loadWishContent(taskId, tempDir)).toBe(genieContent); - }); - }); - - describe('findWishInDotWishes()', () => { - it('should return file path when match found', async () => { - const taskId = 'bd-101'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - const filePath = join(wishesDir, 'cool-wish.md'); - await writeFile(filePath, `# Cool\n**Beads:** ${taskId}`); - - expect(await findWishInDotWishes(taskId, tempDir)).toBe(filePath); - }); - - it('should return undefined when no match found', async () => { - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile(join(wishesDir, 'other-wish.md'), '# Other\n**Beads:** bd-different'); - - expect(await findWishInDotWishes('bd-nomatch', tempDir)).toBeUndefined(); - }); - - it('should return undefined when .wishes/ directory does not exist', async () => { - expect(await findWishInDotWishes('bd-1', tempDir)).toBeUndefined(); - }); - - it('should handle taskIds with special regex characters', async () => { - const taskId = 'bd-1.2+3'; - const wishesDir = join(tempDir, '.wishes'); - await mkdir(wishesDir, { recursive: true }); - await writeFile(join(wishesDir, 'special-wish.md'), `# Special\n**Beads:** ${taskId}`); - - expect(await findWishInDotWishes(taskId, tempDir)).toBeTruthy(); - }); - }); -}); - -// ============================================================================ -// .env sourcing from root repo in worktrees -// ============================================================================ - -describe('genie work - .env sourcing in worktrees', () => { - describe('buildEnvSourcePrefix', () => { - it('should return empty string when workingDir equals repoPath (not a worktree)', () => { - const repoPath = '/home/user/project'; - const workingDir = '/home/user/project'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - expect(result).toBe(''); - }); - - it('should return source prefix when workingDir differs from repoPath (is a worktree)', () => { - const repoPath = '/home/user/project'; - const workingDir = '/home/user/project/.genie/worktrees/task-1'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - expect(result).toBe(`[ -f '/home/user/project/.env' ] && set -a && source '/home/user/project/.env' && set +a; `); - }); - - it('should escape single quotes in repoPath', () => { - const repoPath = "/home/user/it's-a-project"; - const workingDir = "/home/user/it's-a-project/.genie/worktrees/task-1"; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - expect(result).toContain("'\\''"); - expect(result).toContain('set -a'); - expect(result).toContain('set +a'); - }); - - it('should use [ -f ] test to conditionally source', () => { - const repoPath = '/repo'; - const workingDir = '/repo/.genie/worktrees/task'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - expect(result).toStartWith("[ -f '/repo/.env' ]"); - }); - - it('should end with semicolon and space for command chaining', () => { - const repoPath = '/repo'; - const workingDir = '/repo/.genie/worktrees/task'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - expect(result).toEndWith('; '); - }); - - it('should produce valid shell syntax with set -a/+a for export', () => { - const repoPath = '/my/repo'; - const workingDir = '/my/repo/.genie/worktrees/issue-42'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - // Verify the full expected pattern - expect(result).toBe(`[ -f '/my/repo/.env' ] && set -a && source '/my/repo/.env' && set +a; `); - }); - - it('should handle paths with spaces', () => { - const repoPath = '/home/user/my project'; - const workingDir = '/home/user/my project/.genie/worktrees/task'; - - const result = buildEnvSourcePrefix(workingDir, repoPath); - // Single-quoted paths handle spaces fine in shell - expect(result).toContain("'/home/user/my project/.env'"); - }); - - it('should return empty string for identical paths even if they look like worktrees', () => { - // Edge case: path looks like a worktree but equals repoPath - const path = '/repo/.genie/worktrees/task'; - - const result = buildEnvSourcePrefix(path, path); - expect(result).toBe(''); - }); - }); -}); diff --git a/src/term-commands/work.ts b/src/term-commands/work.ts deleted file mode 100644 index d7737b851..000000000 --- a/src/term-commands/work.ts +++ /dev/null @@ -1,1350 +0,0 @@ -/** - * Work command - Spawn worker bound to beads issue - * - * Usage: - * genie work <bd-id> - Work on specific beads issue - * genie work next - Work on next ready issue - * genie work wish - Create a new wish (deferred) - * - * Options: - * --no-worktree - Use shared repo instead of worktree - * -s, --session <name> - Target tmux session - * --focus - Focus the worker pane (default: false) - * --resume - Resume previous Claude session if available (default: true) - * --no-resume - Start fresh session even if previous exists - * --skill <name> - Skill to invoke (e.g., 'forge'). Auto-detects 'forge' if wish.md exists. - * --repo <path> - Target a specific nested repo (e.g., 'code/genie-cli') - * --profile <name> - Worker profile to use (from ~/.genie/config.json workerProfiles) - */ - -import { randomUUID } from 'node:crypto'; -import { isAbsolute, join, resolve } from 'node:path'; -import { $ } from 'bun'; -import * as registry from '../lib/agent-registry.js'; -import { type AutoApproveEngine, createAutoApproveEngine, sendApprovalViaTmux } from '../lib/auto-approve-engine.js'; -import { loadFullAutoApproveConfig } from '../lib/auto-approve.js'; -import * as beadsRegistry from '../lib/beads-registry.js'; -import type { PermissionRequest } from '../lib/event-listener.js'; -import { getDefaultWorkerProfile, getSessionName, getWorkerProfile, loadGenieConfig } from '../lib/genie-config.js'; -import { EventMonitor } from '../lib/orchestrator/index.js'; -import { extractPermissionDetails } from '../lib/orchestrator/state-detector.js'; -import { buildSpawnCommand } from '../lib/spawn-command.js'; -import { getBackend } from '../lib/task-backend.js'; -import * as tmux from '../lib/tmux.js'; -import { getWorktreeManager } from '../lib/worktree-manager.js'; -import type { WorkerProfile } from '../types/genie-config.js'; -import { cleanupEventFile } from './events.js'; - -// Use beads registry only when enabled AND bd exists on PATH -// (macro repos like blanco may run without bd) -const useBeads = - beadsRegistry.isBeadsRegistryEnabled() && - (() => { - const BunExt = Bun as unknown as { which?: (name: string) => string | null }; - return typeof BunExt.which === 'function' ? Boolean(BunExt.which('bd')) : true; - })(); - -// ============================================================================ -// Types -// ============================================================================ - -export interface WorkOptions { - noWorktree?: boolean; - session?: string; - focus?: boolean; - prompt?: string; - /** Resume previous Claude session if available */ - resume?: boolean; - /** Skill to invoke (e.g., 'forge'). Auto-detected from wish.md if not specified. */ - skill?: string; - /** Target a specific nested repo (e.g., 'code/genie-cli') */ - repo?: string; - /** Disable auto-approve for this worker */ - noAutoApprove?: boolean; - /** Worker profile to use (from ~/.genie/config.json workerProfiles) */ - profile?: string; - /** Custom worker name (for N workers per task) */ - name?: string; - /** Worker role (for N workers per task, e.g., "main", "tests", "review") */ - role?: string; - /** Share worktree with existing worker on same task */ - sharedWorktree?: boolean; - /** Internal: skip auto-approve blocking loop (used by spawn-parallel) */ - _skipAutoApproveBlock?: boolean; -} - -/** - * Parsed wish metadata from wish.md - */ -interface WishMetadata { - title?: string; - status?: string; - slug?: string; - repo?: string; - description?: string; -} - -interface BeadsIssue { - id: string; - title: string; - status: string; - description?: string; - blockedBy?: string[]; -} - -// ============================================================================ -// Configuration -// ============================================================================ - -/** - * Known nested repo patterns for heuristic detection - * Maps keywords in wish title/description to relative repo paths - */ -const KNOWN_NESTED_REPOS: Record<string, string> = { - 'genie-cli': 'code/genie-cli', - 'genie work': 'code/genie-cli', - 'genie agent': 'code/genie-cli', -}; - -// ============================================================================ -// Env Loading Helpers -// ============================================================================ - -/** - * Build a shell prefix that sources .env from the root repo when running in a worktree. - * Returns empty string when workingDir === repoPath (not in a worktree). - * - * Pattern: [ -f '/root/.env' ] && set -a && source '/root/.env' && set +a; - * - `set -a` causes all subsequently defined variables to be exported - * - `source` reads the .env file - * - `set +a` disables auto-export - */ -export function buildEnvSourcePrefix(workingDir: string, repoPath: string): string { - if (workingDir === repoPath) { - return ''; - } - const escapedRepoPath = repoPath.replace(/'/g, "'\\''"); - return `[ -f '${escapedRepoPath}/.env' ] && set -a && source '${escapedRepoPath}/.env' && set +a; `; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * Run bd command and parse output - */ -async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> { - try { - const result = await $`bd ${args}`.quiet(); - return { stdout: result.stdout.toString().trim(), exitCode: 0 }; - } catch (error) { - const shellErr = error as { stdout?: Buffer; exitCode?: number }; - return { stdout: shellErr.stdout?.toString().trim() || '', exitCode: shellErr.exitCode || 1 }; - } -} - -/** - * Validate and sanitize a task ID for safe use in shell commands, git branch names, and file paths. - * Returns the sanitized ID or null if the input is fundamentally unsafe. - */ -function sanitizeTaskId(raw: string): string | null { - if (!raw || raw.length > 128) return null; - // Strip leading/trailing whitespace - const trimmed = raw.trim(); - if (!trimmed) return null; - // Only allow alphanumeric, hyphens, underscores, dots, and slashes (for bd-style IDs like "genie-8bu") - // Reject anything that could be shell metacharacters or git-invalid - if (!/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/.test(trimmed)) return null; - // Reject prototype pollution keys - if (trimmed === '__proto__' || trimmed === 'constructor' || trimmed === 'prototype') return null; - // Reject git-invalid patterns (double dots, trailing dots/slashes, lock suffix) - if (/\.\./.test(trimmed) || /[./]$/.test(trimmed) || /\.lock$/.test(trimmed)) return null; - return trimmed; -} - -/** - * Parse raw beads issue JSON into a BeadsIssue object. - */ -function parseBeadsIssue(data: unknown): BeadsIssue | null { - if (!data || typeof data !== 'object') return null; - const d = data as Record<string, unknown>; - return { - id: d.id as string, - title: (d.title as string) || (d.description as string)?.substring(0, 50) || 'Untitled', - status: d.status as string, - description: d.description as string | undefined, - blockedBy: (d.blockedBy as string[]) || [], - }; -} - -/** - * Get a beads issue by ID. - */ -async function getBeadsIssue(id: string): Promise<BeadsIssue | null> { - const { stdout, exitCode } = await runBd(['show', id, '--json']); - - if (exitCode === 0 && stdout) { - try { - const parsed = JSON.parse(stdout); - const issue = Array.isArray(parsed) ? parsed[0] : parsed; - return parseBeadsIssue(issue); - } catch { - // JSON parse failed - } - } - - return null; -} - -/** - * Parse beads `bd ready --json` output into a BeadsIssue. - * Falls back to line-based parsing when JSON is invalid. - */ -async function parseBeadsReadyOutput(stdout: string): Promise<BeadsIssue | null> { - try { - const issues = JSON.parse(stdout); - if (!Array.isArray(issues) || issues.length === 0) return null; - const issue = issues[0]; - return { - id: issue.id, - title: issue.title || issue.description?.substring(0, 50) || 'Untitled', - status: issue.status, - description: issue.description, - blockedBy: issue.blockedBy || [], - }; - } catch { - // JSON parse failed — try line-based fallback - const lines = stdout.split('\n').filter((l) => l.trim()); - if (lines.length === 0) return null; - const match = lines[0].match(/^(bd-\d+)/); - return match ? getBeadsIssue(match[1]) : null; - } -} - -/** - * Get next ready beads issue - */ -async function getNextReadyIssue(repoPath: string): Promise<BeadsIssue | null> { - // If local backend is active, use its queue and synthesize a BeadsIssue-like object. - const backend = getBackend(repoPath); - if (backend.kind === 'local') { - const q = await backend.queue(); - if (q.ready.length === 0) return null; - const id = q.ready[0]; - const t = await backend.get(id); - if (!t) return null; - return { - id: t.id, - title: t.title, - status: t.status, - description: t.description, - blockedBy: t.blockedBy || [], - }; - } - - // beads backend - const { stdout, exitCode } = await runBd(['ready', '--json']); - if (exitCode !== 0 || !stdout) return null; - return parseBeadsReadyOutput(stdout); -} - -/** - * Mark beads issue as in_progress - */ -async function claimIssue(id: string): Promise<boolean> { - const { exitCode } = await runBd(['update', id, '--status', 'in_progress']); - return exitCode === 0; -} - -/** - * Parse wish.md file for metadata including repo field - */ -async function parseWishMetadata(wishPath: string): Promise<WishMetadata> { - const fs = await import('node:fs/promises'); - const metadata: WishMetadata = {}; - - try { - const content = await fs.readFile(wishPath, 'utf-8'); - const lines = content.split('\n'); - - // Parse title from first heading - const titleMatch = lines[0]?.match(/^#\s+(?:Wish\s+\d+:\s+)?(.+)$/i); - if (titleMatch) { - metadata.title = titleMatch[1].trim(); - } - - // Parse metadata fields like **Status:**, **Slug:**, **repo:** etc. - for (const line of lines.slice(0, 20)) { - // Only check first 20 lines - const statusMatch = line.match(/^\*\*Status:\*\*\s*(.+)$/i); - if (statusMatch) { - metadata.status = statusMatch[1].trim(); - continue; - } - - const slugMatch = line.match(/^\*\*Slug:\*\*\s*`?([^`]+)`?$/i); - if (slugMatch) { - metadata.slug = slugMatch[1].trim(); - continue; - } - - // Match repo: field (case insensitive) - const repoMatch = line.match(/^\*\*repo:\*\*\s*`?([^`]+)`?$/i); - if (repoMatch) { - metadata.repo = repoMatch[1].trim(); - } - } - - // Get description from Summary section if present - const summaryIndex = content.indexOf('## Summary'); - if (summaryIndex !== -1) { - const afterSummary = content.slice(summaryIndex + 10); - const nextSection = afterSummary.indexOf('\n## '); - const summaryContent = nextSection !== -1 ? afterSummary.slice(0, nextSection) : afterSummary.slice(0, 500); - metadata.description = summaryContent.trim(); - } - - return metadata; - } catch { - return metadata; - } -} - -/** - * Detect target repo using heuristics from wish title and description - * Returns relative path to nested repo or null if no match - */ -async function detectRepoFromHeuristics( - title: string, - description: string | undefined, - repoPath: string, -): Promise<string | null> { - const fs = await import('node:fs/promises'); - const searchText = `${title} ${description || ''}`.toLowerCase(); - - for (const [keyword, relativePath] of Object.entries(KNOWN_NESTED_REPOS)) { - if (searchText.includes(keyword.toLowerCase())) { - // Verify the path exists and is a git repo - const fullPath = join(repoPath, relativePath); - try { - const gitPath = join(fullPath, '.git'); - await fs.access(gitPath); - return relativePath; - } catch { - // Path doesn't exist or isn't a git repo, continue checking - } - } - } - - return null; -} - -/** - * Detect the target repository for worktree creation - * - * Priority order: - * 1. Explicit --repo flag - * 2. repo: field in wish.md metadata - * 3. Heuristic detection from wish title/description - * 4. Default: use current repo (repoPath) - * - * @returns Absolute path to the target repository - */ -async function detectTargetRepo( - taskId: string, - repoPath: string, - explicitRepo?: string, - issueTitle?: string, - issueDescription?: string, -): Promise<{ targetRepo: string; detectionMethod: string }> { - // 1. Explicit --repo flag takes priority - if (explicitRepo) { - const targetPath = isAbsolute(explicitRepo) ? explicitRepo : resolve(repoPath, explicitRepo); - return { targetRepo: targetPath, detectionMethod: '--repo flag' }; - } - - // 2. Check wish.md for repo: field - const wishPath = join(repoPath, '.genie', 'wishes', taskId, 'wish.md'); - const metadata = await parseWishMetadata(wishPath); - - if (metadata.repo) { - const targetPath = isAbsolute(metadata.repo) ? metadata.repo : resolve(repoPath, metadata.repo); - return { targetRepo: targetPath, detectionMethod: 'wish.md repo: field' }; - } - - // 3. Heuristic detection from title/description - const title = metadata.title || issueTitle || ''; - const description = metadata.description || issueDescription || ''; - const heuristicPath = await detectRepoFromHeuristics(title, description, repoPath); - - if (heuristicPath) { - const targetPath = resolve(repoPath, heuristicPath); - return { targetRepo: targetPath, detectionMethod: `heuristic (matched "${heuristicPath}")` }; - } - - // 4. Default: use current repo - return { targetRepo: repoPath, detectionMethod: 'default (current repo)' }; -} - -/** - * Get current tmux session name - */ -async function getCurrentSession(): Promise<string | null> { - try { - const result = await tmux.executeTmux(`display-message -p '#{session_name}'`); - return result.trim() || null; - } catch { - return null; - } -} - -/** - * Get or create a tmux session. - * If already in a tmux session, returns the current session name. - * Otherwise, auto-creates a new detached session using the configured name. - */ -async function getOrCreateSession(sessionOption?: string): Promise<string> { - // If session was explicitly provided via --session, use that - if (sessionOption) return sessionOption; - - // Try to get the current session (works when inside tmux) - const current = await getCurrentSession(); - if (current) return current; - - // Not inside tmux — auto-create a detached session - const configName = getSessionName(); // defaults to "genie" - const sessionName = configName || 'genie-workers'; - - // Check if a session with this name already exists - const existing = await tmux.findSessionByName(sessionName); - if (existing) { - console.log(`📺 Found existing tmux session '${sessionName}'. Attach with: tmux attach -t ${sessionName}`); - return sessionName; - } - - // Create a new detached session - const created = await tmux.createSession(sessionName); - if (!created) { - console.error('❌ Failed to create tmux session. Is tmux installed?'); - process.exit(1); - } - - console.log(`📺 Created tmux session '${sessionName}'. Attach with: tmux attach -t ${sessionName}`); - return sessionName; -} - -/** - * Create worktree for worker using WorktreeManager - * Creates worktree in .genie/worktrees/<taskId> with branch work/<taskId> - */ -async function createWorktreeForTask(taskId: string, repoPath: string): Promise<string | null> { - try { - const manager = await getWorktreeManager(repoPath); - const info = await manager.create(taskId, repoPath); - return info.path; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`⚠️ Failed to create worktree: ${message}`); - return null; - } -} - -/** - * Search .wishes/ directory recursively for a *-wish.md file - * whose content references the given taskId in a Beads field. - * Returns the file path if found, undefined otherwise. - */ -export async function findWishInDotWishes(taskId: string, repoPath: string): Promise<string | undefined> { - const fs = await import('node:fs/promises'); - const wishesDir = join(repoPath, '.wishes'); - try { - await fs.access(wishesDir); - } catch { - return undefined; - } - - // Recursively find all *-wish.md files - async function findWishFiles(dir: string): Promise<string[]> { - const results: string[] = []; - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return results; - } - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...(await findWishFiles(fullPath))); - } else if (entry.isFile() && entry.name.endsWith('-wish.md')) { - results.push(fullPath); - } - } - return results; - } - - const wishFiles = await findWishFiles(wishesDir); - // Patterns to match: **Beads:** <taskId> or Beads: <taskId> - const patterns = [ - new RegExp(`\\*\\*Beads:\\*\\*\\s*${escapeRegExp(taskId)}`), - new RegExp(`^Beads:\\s*${escapeRegExp(taskId)}`, 'm'), - ]; - - for (const filePath of wishFiles) { - try { - const content = await fs.readFile(filePath, 'utf-8'); - for (const pattern of patterns) { - if (pattern.test(content)) { - return filePath; - } - } - } catch { - // Skip unreadable files - } - } - return undefined; -} - -/** - * Escape special regex characters in a string - */ -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Check if a wish.md file exists for the given task. - * First checks .genie/wishes/<taskId>/wish.md (fast path), - * then searches .wishes/ directory for *-wish.md files referencing the taskId. - */ -export async function wishFileExists(taskId: string, repoPath: string): Promise<boolean> { - const fs = await import('node:fs/promises'); - // Fast path: check .genie/wishes/<taskId>/wish.md - const wishPath = join(repoPath, '.genie', 'wishes', taskId, 'wish.md'); - try { - await fs.access(wishPath); - return true; - } catch { - // Not found in primary location, search .wishes/ directory - } - - // Fallback: search .wishes/ directory - const found = await findWishInDotWishes(taskId, repoPath); - return found !== undefined; -} - -/** - * Load wish.md content for auto-approve overrides. - * First checks .genie/wishes/<taskId>/wish.md (fast path), - * then searches .wishes/ directory for *-wish.md files referencing the taskId. - */ -export async function loadWishContent(taskId: string, repoPath: string): Promise<string | undefined> { - const fs = await import('node:fs/promises'); - // Fast path: check .genie/wishes/<taskId>/wish.md - const wishPath = join(repoPath, '.genie', 'wishes', taskId, 'wish.md'); - try { - return await fs.readFile(wishPath, 'utf-8'); - } catch { - // Not found in primary location, search .wishes/ directory - } - - // Fallback: search .wishes/ directory - const foundPath = await findWishInDotWishes(taskId, repoPath); - if (foundPath) { - try { - return await fs.readFile(foundPath, 'utf-8'); - } catch { - return undefined; - } - } - return undefined; -} - -/** - * Create and start an auto-approve engine for a task. - * Loads config hierarchy: global → repo → wish-level overrides. - */ -async function createEngineForTask( - taskId: string, - repoPath: string, - targetRepo: string, -): Promise<AutoApproveEngine | undefined> { - try { - const wishContent = await loadWishContent(taskId, repoPath); - const config = await loadFullAutoApproveConfig(targetRepo, wishContent); - const engine = createAutoApproveEngine({ - config, - auditDir: repoPath, - sendApproval: sendApprovalViaTmux, - }); - engine.start(); - return engine; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.log(`⚠️ Auto-approve setup failed: ${message} (non-fatal)`); - return undefined; - } -} - -/** - * Block the process to keep auto-approve monitoring alive. - * Resolves on SIGINT (Ctrl+C). - */ -async function blockForAutoApprove(engine: AutoApproveEngine): Promise<void> { - console.log('\n🔒 Auto-approve active. Press Ctrl+C to detach.'); - - return new Promise<void>((resolve) => { - const cleanup = () => { - engine.stop(); - const stats = engine.getStats(); - console.log( - `\n🔒 Auto-approve stopped. (${stats.approved} approved, ${stats.denied} denied, ${stats.escalated} escalated)`, - ); - resolve(); - }; - - process.on('SIGINT', () => { - cleanup(); - process.exit(0); - }); - }); -} - -/** - * Wait for Claude CLI to be ready to accept input - * Polls pane content looking for Claude's input prompt indicator - */ -async function waitForClaudeReady(paneId: string, timeoutMs = 30000, pollIntervalMs = 500): Promise<boolean> { - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - try { - const content = await tmux.capturePaneContent(paneId, 50); - - // Claude CLI shows ">" prompt when ready for input - // Also check for the input area indicator - // The prompt appears at the end of output when Claude is waiting for input - const lines = content.split('\n').filter((l) => l.trim()); - if (lines.length > 0) { - const lastFewLines = lines.slice(-5).join('\n'); - // Claude shows "❯" prompt when ready for input - // Also detect welcome messages or input hints - if ( - lastFewLines.includes('❯') || - lastFewLines.includes('? for shortcuts') || - lastFewLines.includes('What would you like') || - lastFewLines.includes('How can I help') - ) { - return true; - } - } - } catch { - // Pane may not exist yet, continue polling - } - - await new Promise((r) => setTimeout(r, pollIntervalMs)); - } - - // Timeout - return false but don't fail (caller can decide) - return false; -} - -/** - * Ensure a dedicated tmux window exists for the task and return pane 0. - * Idempotent: if the window already exists, returns its first pane. - */ -async function ensureWorkerWindow( - session: string, - taskId: string, - workingDir: string, -): Promise<{ paneId: string; windowId: string; windowCreated: boolean } | null> { - try { - // Find session - const sessionObj = await tmux.findSessionByName(session); - if (!sessionObj) { - console.error(`❌ Session "${session}" not found`); - return null; - } - - // Check if window already exists for this task - const existingWindow = await tmux.findWindowByName(sessionObj.id, taskId); - if (existingWindow) { - // Window exists -- get pane 0 - const panes = await tmux.listPanes(existingWindow.id); - if (!panes || panes.length === 0) { - console.error(`❌ No panes in existing window "${taskId}"`); - return null; - } - return { paneId: panes[0].id, windowId: existingWindow.id, windowCreated: false }; - } - - // Create new window named after the task - const newWindow = await tmux.createWindow(sessionObj.id, taskId, workingDir); - if (!newWindow) { - console.error(`❌ Failed to create window "${taskId}"`); - return null; - } - - // Get pane 0 of the new window - const panes = await tmux.listPanes(newWindow.id); - if (!panes || panes.length === 0) { - console.error(`❌ No panes in new window "${taskId}"`); - return null; - } - - return { paneId: panes[0].id, windowId: newWindow.id, windowCreated: true }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error ensuring worker window: ${message}`); - return null; - } -} - -/** - * Resolve tool name and input from raw permission output. - */ -function resolveToolFromPermission( - rawOutput: string, - details: ReturnType<typeof extractPermissionDetails>, -): { toolName: string; toolInput: Record<string, unknown> | undefined } { - if (!details) return { toolName: 'unknown', toolInput: undefined }; - - switch (details.type) { - case 'bash': - return { - toolName: 'Bash', - toolInput: details.command ? { command: details.command } : undefined, - }; - case 'file': { - let toolName = 'Write'; // conservative default - if (/Allow.*Edit/i.test(rawOutput)) toolName = 'Edit'; - else if (/Allow.*Write/i.test(rawOutput)) toolName = 'Write'; - else if (/Allow.*Read/i.test(rawOutput)) toolName = 'Read'; - return { - toolName, - toolInput: details.file ? { file_path: details.file } : undefined, - }; - } - default: - return { toolName: details.type, toolInput: undefined }; - } -} - -/** - * Start monitoring worker state and update registry - * Updates both beads and JSON registry during transition - */ -function startWorkerMonitoring(workerId: string, session: string, paneId: string, engine?: AutoApproveEngine): void { - const monitor = new EventMonitor(session, { - pollIntervalMs: 1000, - paneId, - }); - - // Auto-approve: evaluate permission requests via the engine - if (engine) { - let lastApprovalTime = 0; - - monitor.on('permission', async (event) => { - // Debounce: skip if we just approved within 2s - const now = Date.now(); - if (now - lastApprovalTime < 2000) return; - - const rawOutput = event.state?.rawOutput || ''; - const details = extractPermissionDetails(rawOutput); - const { toolName, toolInput } = resolveToolFromPermission(rawOutput, details); - - const request: PermissionRequest = { - id: `auto-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, - toolName, - toolInput, - paneId, - wishId: workerId, - sessionId: '', - cwd: '', - timestamp: new Date().toISOString(), - }; - - const decision = await engine.processRequest(request); - if (decision.action === 'approve') { - lastApprovalTime = now; - console.log( - ` ✅ Auto-approved: ${toolName}${details?.command ? ` (${details.command.substring(0, 50)})` : ''}`, - ); - } else if (decision.action === 'deny') { - console.log(` ❌ Auto-denied: ${toolName} - ${decision.reason}`); - } - // escalate = do nothing, human must decide - }); - } - - monitor.on('state_change', async (event) => { - if (!event.state) return; - - let newState: registry.AgentState; - switch (event.state.type) { - case 'working': - case 'tool_use': - newState = 'working'; - break; - case 'idle': - newState = 'idle'; - break; - case 'permission': - newState = 'permission'; - break; - case 'question': - newState = 'question'; - break; - case 'error': - newState = 'error'; - break; - case 'complete': - newState = 'done'; - break; - default: - return; // Don't update for unknown states - } - - try { - // Update both registries during transition - if (useBeads) { - await beadsRegistry.updateState(workerId, newState); - } - await registry.updateState(workerId, newState); - } catch { - // Ignore errors in background monitoring - } - }); - - monitor.on('poll_error', () => { - // Pane may have been killed - unregister worker - if (useBeads) { - beadsRegistry.unregister(workerId).catch(() => {}); - } - registry.unregister(workerId).catch(() => {}); - // Cleanup event file - cleanupEventFile(paneId).catch(() => {}); - monitor.stop(); - }); - - monitor.start().catch(() => { - // Session/pane not found - ignore - }); - - // Store monitor reference for cleanup (could be enhanced) - // For now, monitoring is fire-and-forget -} - -// ============================================================================ -// workCommand helpers -// ============================================================================ - -/** - * Ensure beads daemon is running for auto-sync. - */ -async function ensureBeadsDaemon(): Promise<void> { - if (!useBeads) return; - const daemonStatus = await beadsRegistry.checkDaemonStatus(); - if (daemonStatus.running) return; - console.log('🔄 Starting beads daemon for auto-sync...'); - const started = await beadsRegistry.startDaemon({ autoCommit: true }); - console.log(started ? ' ✅ Daemon started' : ' ⚠️ Daemon failed to start (non-fatal)'); -} - -/** - * Load and validate worker profile from config. - */ -async function resolveWorkerProfile(profileName?: string): Promise<WorkerProfile | undefined> { - const config = await loadGenieConfig(); - if (!profileName) return getDefaultWorkerProfile(config); - - const profile = getWorkerProfile(config, profileName); - if (profile) return profile; - - const available = Object.keys(config.workerProfiles || {}); - console.error(`Profile '${profileName}' not found.`); - console.log( - available.length > 0 - ? `Available profiles: ${available.join(', ')}` - : 'No profiles configured in ~/.genie/config.json', - ); - process.exit(1); -} - -/** - * Resolve a specific task ID target to a BeadsIssue. - * Checks local backend first, then falls back to beads. - */ -async function resolveSpecificTarget(target: string, repoPath: string): Promise<BeadsIssue> { - const sanitized = sanitizeTaskId(target); - if (!sanitized) { - console.error(`❌ Invalid task ID: "${target}"`); - console.error(' Task IDs must be alphanumeric with hyphens/underscores (e.g., "bd-123", "wish-1").'); - console.error(' Got characters that are unsafe for git branches or shell commands.'); - process.exit(1); - } - - // Check local backend first - const backend = getBackend(repoPath); - if (backend.kind === 'local') { - const localTask = await backend.get(sanitized); - if (localTask) { - return { - id: localTask.id, - title: localTask.title, - status: localTask.status, - description: localTask.description, - blockedBy: localTask.blockedBy || [], - }; - } - } - - // Fall back to beads - const issue = await getBeadsIssue(sanitized); - if (issue) return issue; - - // Not found — print backend-specific error - printTargetNotFoundError(sanitized, repoPath); - process.exit(1); -} - -/** - * Print error message when a target task is not found. - */ -async function printTargetNotFoundError(taskTarget: string, repoPath: string): Promise<void> { - const backend = getBackend(repoPath); - if (backend.kind !== 'local') { - console.error(`❌ Issue "${taskTarget}" not found. Run \`bd list\` to see issues.`); - return; - } - console.error(`❌ Issue "${taskTarget}" not found in local task registry.`); - console.error(` File: ${join(repoPath, '.genie', 'tasks.json')}`); - const fs = await import('node:fs'); - if (!fs.existsSync(join(repoPath, '.genie', 'tasks.json'))) { - console.error(' ⚠️ tasks.json does not exist. This is likely a fresh repo.'); - console.error(' Fix: Run `genie task create "Your task title"` to create the first task,'); - console.error(' or `bd sync` if using beads.'); - } else { - console.error(` Task "${taskTarget}" is not in tasks.json. Run \`bd list\` to see available tasks.`); - } -} - -/** - * Resolve work target to a BeadsIssue. - * Handles 'next', 'wish', and specific task ID targets. - * Returns null only for 'next' with no ready issues (caller should return early). - */ -async function resolveTarget(target: string, repoPath: string): Promise<BeadsIssue | null> { - if (target === 'next') { - console.log('🔍 Finding next ready issue...'); - const issue = await getNextReadyIssue(repoPath); - if (!issue) { - console.log('ℹ️ No ready issues. Run `bd ready` to see the queue.'); - return null; - } - console.log(`📋 Found: ${issue.id} - "${issue.title}"`); - return issue; - } - - if (target === 'wish') { - console.error('❌ `genie work wish` is not yet implemented.'); - process.exit(1); - } - - return resolveSpecificTarget(target, repoPath); -} - -/** - * Resume an existing worker session. - * Returns true if resumed successfully (caller should return), false if not resumable. - */ -async function resumeExistingWorker( - existingWorker: registry.Agent, - taskId: string, - options: WorkOptions, - workerProfile: WorkerProfile | undefined, -): Promise<boolean> { - if (!existingWorker.claudeSessionId || options.resume === false) return false; - - console.log(`📋 Found existing worker for ${taskId} with resumable session`); - console.log(` Session ID: ${existingWorker.claudeSessionId}`); - console.log(' Resuming previous Claude session...'); - - const session = await getOrCreateSession(options.session); - const workingDir = existingWorker.worktree || existingWorker.repoPath; - - console.log('🚀 Ensuring worker window...'); - const paneResult = await ensureWorkerWindow(session, taskId, workingDir); - if (!paneResult) process.exit(1); - - const { paneId, windowId } = paneResult; - - // Update registry with new pane/window info - await registry.update(existingWorker.id, { - paneId, - session, - windowName: taskId, - windowId, - state: 'spawning', - lastStateChange: new Date().toISOString(), - }); - if (useBeads) { - await beadsRegistry.setAgentState(existingWorker.id, 'spawning').catch(() => {}); - } - - // Launch Claude in the pane - const beadsDir = join(existingWorker.repoPath, '.genie'); - const escapedWorkingDir = workingDir.replace(/'/g, "'\\''"); - const resumeCmd = buildSpawnCommand(workerProfile, { - resume: existingWorker.claudeSessionId, - beadsDir, - }); - const resumeEnvPrefix = buildEnvSourcePrefix(workingDir, existingWorker.repoPath); - await tmux.executeCommand(paneId, `cd '${escapedWorkingDir}' && ${resumeEnvPrefix}${resumeCmd}`, true, false); - - // Update state to working - if (useBeads) { - await beadsRegistry.setAgentState(existingWorker.id, 'working').catch(() => {}); - } - await registry.updateState(existingWorker.id, 'working'); - - // Auto-approve + monitoring - let resumeEngine: AutoApproveEngine | undefined; - if (!options.noAutoApprove) { - resumeEngine = await createEngineForTask(taskId, existingWorker.repoPath, existingWorker.repoPath); - if (resumeEngine) console.log('🔒 Auto-approve engine started'); - } - startWorkerMonitoring(existingWorker.id, session, paneId, resumeEngine); - - if (options.focus === true) { - await tmux.executeTmux(`select-window -t '${session}:${taskId}'`); - } - - printWorkerStatus('Resumed', taskId, paneId, session, existingWorker.claudeSessionId); - - if (resumeEngine && !options._skipAutoApproveBlock) { - await blockForAutoApprove(resumeEngine); - } - return true; -} - -/** - * Claim a task via the appropriate backend, exiting on failure. - */ -async function claimTaskOrExit(taskId: string, repoPath: string): Promise<void> { - console.log(`📝 Claiming ${taskId}...`); - const backend = getBackend(repoPath); - let claimed = false; - let claimError: string | undefined; - - try { - claimed = await (backend.kind === 'local' ? backend.claim(taskId) : claimIssue(taskId)); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - claimError = message || String(err); - } - - if (claimed) return; - - if (backend.kind === 'beads') { - console.error(`❌ Failed to claim ${taskId}.${claimError ? ` Reason: ${claimError}` : ''}`); - console.error(' The issue may not exist or is already claimed.'); - console.error(` Run \`bd show ${taskId}\` to check status.`); - process.exit(1); - } - - // Local backend - const task = await backend.get(taskId); - if (!task) { - console.error(`❌ Task "${taskId}" not found in .genie/tasks.json.`); - console.error(` Available tasks: run \`cat .genie/tasks.json | jq '.order'\``); - console.error(` Or create one: \`term create "${taskId} title"\``); - } else { - console.error(`❌ Failed to claim ${taskId} (status: ${task.status}).`); - console.error(' Task may already be in_progress or done.'); - } - process.exit(1); -} - -/** - * Register worker in beads registry (non-fatal on error). - */ -async function registerInBeads( - taskId: string, - opts: { - paneId: string; - session: string; - worktree: string | null; - repoPath: string; - taskTitle: string; - claudeSessionId: string; - }, -): Promise<void> { - if (!useBeads) return; - try { - await beadsRegistry.ensureAgent(taskId, { - paneId: opts.paneId, - session: opts.session, - worktree: opts.worktree, - repoPath: opts.repoPath, - taskId, - taskTitle: opts.taskTitle, - claudeSessionId: opts.claudeSessionId, - }); - await beadsRegistry.bindWork(taskId, taskId); - await beadsRegistry.setAgentState(taskId, 'spawning'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`⚠️ Beads registration failed: ${message} (non-fatal)`); - } -} - -/** - * Build the worker prompt based on skill detection and options. - */ -async function buildWorkerPrompt( - taskId: string, - issue: BeadsIssue, - options: WorkOptions, - repoPath: string, -): Promise<string> { - let skill = options.skill; - if (!skill && !options.prompt) { - const hasWish = await wishFileExists(taskId, repoPath); - if (hasWish) { - skill = 'forge'; - console.log('📋 Found wish.md - using /forge skill'); - } - } - - if (skill) return `/${skill}`; - - return ( - options.prompt || - `Work on beads issue ${taskId}: "${issue.title}" - -## Description -${issue.description || 'No description provided.'} - -When you're done, commit your changes and let me know.` - ); -} - -/** - * Print worker status and help commands. - */ -function printWorkerStatus( - verb: string, - taskId: string, - paneId: string, - session: string, - claudeSessionId?: string, - extra?: { worktreePath?: string | null; targetRepo?: string; repoPath?: string }, -): void { - console.log(`\n✅ ${verb} worker for ${taskId}`); - console.log(` Window: ${taskId}`); - console.log(` Pane: ${paneId}`); - console.log(` Session: ${session}`); - if (claudeSessionId) console.log(` Claude Session: ${claudeSessionId}`); - if (extra?.worktreePath) { - console.log(` Worktree: ${extra.worktreePath}`); - console.log(` Branch: work/${taskId}`); - } - if (extra?.targetRepo && extra?.repoPath && extra.targetRepo !== extra.repoPath) { - console.log(` Target repo: ${extra.targetRepo}`); - } - console.log('\nCommands:'); - console.log(' genie agent list - Check agent status'); - console.log(' genie agent approve - Approve permissions'); - console.log(` genie agent close ${taskId} - Close issue when done`); - console.log(` genie agent kill ${taskId} - Force kill agent`); -} - -/** - * Create worktree for task if enabled, returning the working directory and worktree path. - */ -async function setupWorktree( - taskId: string, - targetRepo: string, - noWorktree?: boolean, -): Promise<{ workingDir: string; worktreePath: string | null }> { - if (noWorktree) return { workingDir: targetRepo, worktreePath: null }; - - console.log(`🌳 Creating worktree for ${taskId} in ${targetRepo}...`); - const worktreePath = await createWorktreeForTask(taskId, targetRepo); - if (!worktreePath) { - console.log('⚠️ Worktree creation failed. Using shared repo.'); - return { workingDir: targetRepo, worktreePath: null }; - } - console.log(` Created: ${worktreePath}`); - console.log(` Branch: work/${taskId}`); - return { workingDir: worktreePath, worktreePath }; -} - -/** - * Spawn Claude in a pane, wait for ready, then send the prompt. - */ -async function spawnAndSendPrompt( - paneId: string, - workingDir: string, - repoPath: string, - claudeSessionId: string, - prompt: string, - workerProfile: WorkerProfile | undefined, -): Promise<void> { - const escapedPrompt = prompt.replace(/'/g, "'\\''"); - const beadsDir = join(repoPath, '.genie'); - const escapedWorkingDir = workingDir.replace(/'/g, "'\\''"); - const spawnCmd = buildSpawnCommand(workerProfile, { sessionId: claudeSessionId, beadsDir }); - const spawnEnvPrefix = buildEnvSourcePrefix(workingDir, repoPath); - await tmux.executeCommand(paneId, `cd '${escapedWorkingDir}' && ${spawnEnvPrefix}${spawnCmd}`, true, false); - console.log(` Session ID: ${claudeSessionId}`); - - const ready = await waitForClaudeReady(paneId); - if (!ready) console.log(' (Claude startup timed out, sending prompt anyway)'); - await tmux.executeTmux(`send-keys -t '${paneId}' '${escapedPrompt}' Enter`); -} - -/** - * Find an existing worker for a task across registries. - */ -async function findExistingWorker(taskId: string): Promise<registry.Agent | null> { - const beadsWorker = useBeads ? await beadsRegistry.findByTask(taskId) : null; - return beadsWorker || (await registry.findByTask(taskId)); -} - -/** - * Start auto-approve engine and monitoring for a worker. - */ -async function setupAutoApproveAndMonitoring( - workerId: string, - taskId: string, - session: string, - paneId: string, - repoPath: string, - targetRepo: string, - options: WorkOptions, -): Promise<AutoApproveEngine | undefined> { - let engine: AutoApproveEngine | undefined; - if (!options.noAutoApprove) { - engine = await createEngineForTask(taskId, repoPath, targetRepo); - if (engine) console.log('🔒 Auto-approve engine started'); - } - startWorkerMonitoring(workerId, session, paneId, engine); - return engine; -} - -// ============================================================================ -// Main Command -// ============================================================================ - -export async function workCommand(target: string, options: WorkOptions = {}): Promise<void> { - try { - const repoPath = process.cwd(); - - await ensureBeadsDaemon(); - const workerProfile = await resolveWorkerProfile(options.profile); - - // 1. Resolve target - const issue = await resolveTarget(target, repoPath); - if (!issue) return; // 'next' with no ready issues - - const taskId = issue.id; - - // 2. Check not already assigned - const existingWorker = await findExistingWorker(taskId); - if (existingWorker) { - const resumed = await resumeExistingWorker(existingWorker, taskId, options, workerProfile); - if (resumed) return; - console.error(`❌ ${taskId} already has a worker (pane ${existingWorker.paneId})`); - console.log(` Run \`genie agent kill ${existingWorker.id}\` first, or work on a different issue.`); - process.exit(1); - } - - // 3. Get session + claim task - const session = await getOrCreateSession(options.session); - await claimTaskOrExit(taskId, repoPath); - - // 4. Detect target repo - const { targetRepo, detectionMethod } = await detectTargetRepo( - taskId, - repoPath, - options.repo, - issue.title, - issue.description, - ); - if (targetRepo !== repoPath) { - console.log(`🎯 Detected nested repo: ${targetRepo}`); - console.log(` Detection: ${detectionMethod}`); - } - - // 5. Create worktree - const { workingDir, worktreePath } = await setupWorktree(taskId, targetRepo, options.noWorktree); - - // 6. Ensure dedicated window - console.log(`🚀 Creating worker window "${taskId}"...`); - const paneResult = await ensureWorkerWindow(session, taskId, workingDir); - if (!paneResult) process.exit(1); - const { paneId, windowId } = paneResult; - - // 7. Generate IDs + register - const claudeSessionId = randomUUID(); - const workerId = await registry.generateWorkerId(taskId, options.name); - const worker: registry.Agent = { - id: workerId, - paneId, - session, - worktree: worktreePath, - taskId, - taskTitle: issue.title, - startedAt: new Date().toISOString(), - state: 'spawning', - lastStateChange: new Date().toISOString(), - repoPath: targetRepo, - claudeSessionId, - windowName: taskId, - windowId, - role: options.role, - customName: options.name, - }; - await registerInBeads(taskId, { - paneId, - session, - worktree: worktreePath, - repoPath: targetRepo, - taskTitle: issue.title, - claudeSessionId, - }); - await registry.register(worker); - - // 8. Spawn Claude + send prompt - const prompt = await buildWorkerPrompt(taskId, issue, options, repoPath); - await spawnAndSendPrompt(paneId, workingDir, repoPath, claudeSessionId, prompt, workerProfile); - - // 9. Update state to working - if (useBeads) await beadsRegistry.setAgentState(taskId, 'working').catch(() => {}); - await registry.updateState(taskId, 'working'); - - // 10. Auto-approve + monitoring - const engine = await setupAutoApproveAndMonitoring( - workerId, - taskId, - session, - paneId, - repoPath, - targetRepo, - options, - ); - - if (options.focus === true) await tmux.executeTmux(`select-window -t '${session}:${taskId}'`); - - printWorkerStatus('Started', taskId, paneId, session, undefined, { worktreePath, targetRepo, repoPath }); - - if (engine && !options._skipAutoApproveBlock) await blockForAutoApprove(engine); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ Error: ${message}`); - process.exit(1); - } -} diff --git a/src/types/genie-config.ts b/src/types/genie-config.ts index a0a79ea29..22987327b 100644 --- a/src/types/genie-config.ts +++ b/src/types/genie-config.ts @@ -95,6 +95,4 @@ export const GenieConfigSchema = z.object({ // Inferred types export type TerminalConfig = z.infer<typeof TerminalConfigSchema>; export type ShortcutsConfig = z.infer<typeof ShortcutsConfigSchema>; -export type WorkerProfile = z.infer<typeof WorkerProfileSchema>; -export type CouncilPreset = z.infer<typeof CouncilPresetSchema>; export type GenieConfig = z.infer<typeof GenieConfigSchema>;