diff --git a/gsd-orchestrator/templates/spec.md b/gsd-orchestrator/templates/spec.md new file mode 100644 index 000000000..441880f39 --- /dev/null +++ b/gsd-orchestrator/templates/spec.md @@ -0,0 +1,20 @@ +# [Product Name] + +## What +[One paragraph: what this product does. Be concrete — "A CLI tool that converts CSV files to JSON" not "A data transformation solution".] + +## Requirements +- [User can DO something specific and observable] +- [User can DO another specific thing] +- [System DOES something automatically] +- [Error case: system handles X gracefully] + +## Technical Constraints +- Language: [Node.js / Python / Go / Rust / etc.] +- Framework: [Express / FastAPI / none / etc.] +- External dependencies: [list APIs, databases, services] +- Environment: [Node >= 22 / Python 3.12+ / etc.] + +## Out of Scope +- [Explicit exclusion 1 — prevents scope creep] +- [Explicit exclusion 2] diff --git a/gsd-orchestrator/workflows/build-from-spec.md b/gsd-orchestrator/workflows/build-from-spec.md new file mode 100644 index 000000000..e3c70e02c --- /dev/null +++ b/gsd-orchestrator/workflows/build-from-spec.md @@ -0,0 +1,184 @@ +# Build From Spec + +End-to-end workflow: take a product idea or specification, produce working software. + +## Prerequisites + +- `gsd` CLI installed (`npm install -g gsd-pi`) +- A directory for the project (can be empty) +- Git initialized in the directory + +## Process + +### Step 1: Prepare the project directory + +```bash +PROJECT_DIR="/tmp/my-project-name" +mkdir -p "$PROJECT_DIR" +cd "$PROJECT_DIR" +git init 2>/dev/null # GSD needs a git repo +``` + +### Step 2: Write the spec file + +Write a spec file that describes what to build. More detail = better results. + +```bash +cat > spec.md << 'SPEC' +# Product Name + +## What +[Concrete description of what to build] + +## Requirements +- [Specific, testable requirement 1] +- [Specific, testable requirement 2] +- [Specific, testable requirement 3] + +## Technical Constraints +- [Language, framework, or platform requirements] +- [External services or APIs involved] +- [Performance or security requirements] + +## Out of Scope +- [Things explicitly NOT included] +SPEC +``` + +**Spec quality matters.** Vague specs produce vague results. Include: +- What the user can DO when it's done (not what code to write) +- Technical constraints (language, framework, Node version) +- What's out of scope (prevents scope creep) + +### Step 3: Launch the build + +**Fire-and-forget (simplest — GSD does everything):** +```bash +cd "$PROJECT_DIR" +RESULT=$(gsd headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +EXIT=$? +``` + +`--timeout 0` disables the timeout for long builds. `--auto` chains milestone creation into execution. + +**With budget limit:** +```bash +# Use step-by-step mode with budget checks instead of auto +# See workflows/step-by-step.md +``` + +**For CI or ecosystem runs (no user config):** +```bash +RESULT=$(gsd headless --bare --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +EXIT=$? +``` + +### Step 4: Handle the result + +```bash +case $EXIT in + 0) + # Success — verify deliverables + STATUS=$(echo "$RESULT" | jq -r '.status') + COST=$(echo "$RESULT" | jq -r '.cost.total') + COMMITS=$(echo "$RESULT" | jq -r '.commits | length') + echo "Build complete: $STATUS, cost: \$$COST, commits: $COMMITS" + + # Inspect what was built + gsd headless query | jq '.state.progress' + + # Check the actual files + ls -la "$PROJECT_DIR" + ;; + 1) + # Error — inspect and decide + echo "Build failed" + echo "$RESULT" | jq '{status: .status, phase: .phase}' + + # Check state for details + gsd headless query | jq '.state' + ;; + 10) + # Blocked — needs intervention + echo "Build blocked — needs human input" + gsd headless query | jq '{phase: .state.phase, blockers: .state.blockers}' + + # Options: steer, supply answers, or escalate + # See workflows/monitor-and-poll.md for blocker handling + ;; + 11) + echo "Build was cancelled" + ;; +esac +``` + +### Step 5: Verify deliverables + +After a successful build, verify the output: + +```bash +cd "$PROJECT_DIR" + +# Check project state +gsd headless query | jq '{ + phase: .state.phase, + progress: .state.progress, + cost: .cost.total +}' + +# Check git log for what was built +git log --oneline + +# Run the project's own tests if they exist +[ -f package.json ] && npm test 2>/dev/null +[ -f Makefile ] && make test 2>/dev/null +``` + +## Complete Example + +```bash +# 1. Setup +mkdir -p /tmp/todo-api && cd /tmp/todo-api && git init + +# 2. Write spec +cat > spec.md << 'SPEC' +# Todo API + +Build a REST API for managing todo items using Node.js and Express. + +## Requirements +- GET /todos — list all todos +- POST /todos — create a todo (title, completed) +- PUT /todos/:id — update a todo +- DELETE /todos/:id — delete a todo +- Todos stored in-memory (no database) +- Input validation with descriptive error messages +- Health check endpoint at GET /health + +## Technical Constraints +- Node.js with ESM modules +- Express framework +- No external database — in-memory array +- Port configurable via PORT env var (default 3000) + +## Out of Scope +- Authentication +- Persistent storage +- Frontend +SPEC + +# 3. Launch +RESULT=$(gsd headless --output-format json --timeout 0 --context spec.md new-milestone --auto 2>/dev/null) +EXIT=$? + +# 4. Report +if [ $EXIT -eq 0 ]; then + COST=$(echo "$RESULT" | jq -r '.cost.total') + echo "Build complete (\$$COST)" + echo "Files created:" + find . -not -path './.gsd/*' -not -path './.git/*' -type f +else + echo "Build failed (exit $EXIT)" + echo "$RESULT" | jq . +fi +``` diff --git a/gsd-orchestrator/workflows/monitor-and-poll.md b/gsd-orchestrator/workflows/monitor-and-poll.md new file mode 100644 index 000000000..346cb8613 --- /dev/null +++ b/gsd-orchestrator/workflows/monitor-and-poll.md @@ -0,0 +1,187 @@ +# Monitor and Poll + +Check status of a GSD project, handle blockers, track costs, and decide next actions. + +## Checking Project State + +The `query` command is your primary monitoring tool. It's instant (~50ms), costs nothing (no LLM), and returns the full project snapshot. + +```bash +cd /path/to/project +gsd headless query +``` + +### Key fields to inspect + +```bash +# Overall status +gsd headless query | jq '{ + phase: .state.phase, + milestone: .state.activeMilestone.id, + slice: .state.activeSlice.id, + task: .state.activeTask.id, + progress: .state.progress, + cost: .cost.total +}' + +# What should happen next +gsd headless query | jq '.next' +# Returns: { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" } + +# Is it done? +gsd headless query | jq '.state.phase' +# "complete" = done, "blocked" = needs you, anything else = in progress +``` + +### Phase meanings + +| Phase | Meaning | Your action | +|-------|---------|-------------| +| `pre-planning` | Milestone exists, no slices planned yet | Run `auto` or `next` | +| `needs-discussion` | Ambiguities need resolution | Supply answers or run with defaults | +| `discussing` | Discussion in progress | Wait | +| `researching` | Codebase/library research | Wait | +| `planning` | Creating task plans | Wait | +| `executing` | Writing code | Wait | +| `verifying` | Checking must-haves | Wait | +| `summarizing` | Recording what happened | Wait | +| `advancing` | Moving to next task/slice | Wait | +| `evaluating-gates` | Quality checks before execution | Wait or run `next` | +| `validating-milestone` | Final milestone checks | Wait | +| `completing-milestone` | Archiving and cleanup | Wait | +| `complete` | Done | Verify deliverables | +| `blocked` | Needs human input | Handle blocker (see below) | +| `paused` | Explicitly paused | Resume with `auto` | + +## Handling Blockers + +When exit code is `10` or phase is `blocked`: + +```bash +# 1. Understand the blocker +gsd headless query | jq '{phase: .state.phase, blockers: .state.blockers, nextAction: .state.nextAction}' + +# 2. Option A: Steer around it +gsd headless steer "Skip the database dependency, use in-memory storage instead" + +# 3. Option B: Supply pre-built answers +cat > fix.json << 'EOF' +{ + "questions": { "blocked_question_id": "workaround_option" }, + "defaults": { "strategy": "first_option" } +} +EOF +gsd headless --answers fix.json auto + +# 4. Option C: Force a specific phase +gsd headless dispatch replan + +# 5. Option D: Escalate to user +echo "GSD build blocked. Phase: $(gsd headless query | jq -r '.state.phase')" +echo "Manual intervention required." +``` + +## Cost Tracking + +```bash +# Current cumulative cost +gsd headless query | jq '.cost.total' + +# Per-worker breakdown +gsd headless query | jq '.cost.workers' + +# After a step (from HeadlessJsonResult) +RESULT=$(gsd headless --output-format json next 2>/dev/null) +echo "$RESULT" | jq '.cost' +``` + +### Budget enforcement pattern + +```bash +MAX_BUDGET=15.00 + +check_budget() { + TOTAL=$(gsd headless query | jq -r '.cost.total') + OVER=$(echo "$TOTAL > $MAX_BUDGET" | bc -l) + if [ "$OVER" = "1" ]; then + echo "Budget exceeded: \$$TOTAL > \$$MAX_BUDGET" + gsd headless stop + return 1 + fi + return 0 +} +``` + +## Poll-and-React Loop + +For agents that need to periodically check on a build: + +```bash +cd /path/to/project + +poll_project() { + STATE=$(gsd headless query 2>/dev/null) + if [ -z "$STATE" ]; then + echo "NO_PROJECT" + return + fi + + PHASE=$(echo "$STATE" | jq -r '.state.phase') + COST=$(echo "$STATE" | jq -r '.cost.total') + PROGRESS=$(echo "$STATE" | jq -r '"\(.state.progress.milestones.done)/\(.state.progress.milestones.total) milestones, \(.state.progress.tasks.done)/\(.state.progress.tasks.total) tasks"') + + case "$PHASE" in + complete) + echo "COMPLETE cost=\$$COST progress=$PROGRESS" + ;; + blocked) + BLOCKER=$(echo "$STATE" | jq -r '.state.nextAction // "unknown"') + echo "BLOCKED reason=$BLOCKER cost=\$$COST" + ;; + *) + NEXT=$(echo "$STATE" | jq -r '.next.action // "none"') + echo "IN_PROGRESS phase=$PHASE next=$NEXT cost=\$$COST progress=$PROGRESS" + ;; + esac +} +``` + +## Resuming Work + +If a build was interrupted or you need to continue: + +```bash +cd /path/to/project + +# Check current state +gsd headless query | jq '.state.phase' + +# Resume from where it left off +gsd headless --output-format json auto 2>/dev/null + +# Or resume a specific session +gsd headless --resume "$SESSION_ID" --output-format json auto 2>/dev/null +``` + +## Reading Build Artifacts + +After completion, inspect what GSD produced: + +```bash +cd /path/to/project + +# Project summary +cat .gsd/PROJECT.md + +# What was decided +cat .gsd/DECISIONS.md + +# Requirements and their validation status +cat .gsd/REQUIREMENTS.md + +# Milestone summary +cat .gsd/milestones/M001-*/M001-*-SUMMARY.md 2>/dev/null + +# Git history (GSD commits per-slice) +git log --oneline +``` diff --git a/gsd-orchestrator/workflows/step-by-step.md b/gsd-orchestrator/workflows/step-by-step.md new file mode 100644 index 000000000..1690aa306 --- /dev/null +++ b/gsd-orchestrator/workflows/step-by-step.md @@ -0,0 +1,156 @@ +# Step-by-Step Execution + +Run GSD one unit at a time with decision points between steps. Use this when you need +control over execution — budget enforcement, progress reporting, conditional logic, +or the ability to steer mid-build. + +## When to use this vs `auto` + +| Approach | Use when | +|----------|----------| +| `auto` | You trust the build, just want the result | +| `next` loop | You need budget checks, progress updates, or intervention points | + +## Core Loop + +```bash +cd /path/to/project +MAX_BUDGET=20.00 +TOTAL_COST=0 + +while true; do + # Run one unit + RESULT=$(gsd headless --output-format json next 2>/dev/null) + EXIT=$? + + # Parse result + STATUS=$(echo "$RESULT" | jq -r '.status') + STEP_COST=$(echo "$RESULT" | jq -r '.cost.total') + PHASE=$(echo "$RESULT" | jq -r '.phase // empty') + SESSION_ID=$(echo "$RESULT" | jq -r '.sessionId // empty') + + # Handle exit codes + case $EXIT in + 0) ;; # success — continue + 1) + echo "Step failed: $STATUS" + break + ;; + 10) + echo "Blocked — needs intervention" + gsd headless query | jq '.state' + break + ;; + 11) + echo "Cancelled" + break + ;; + esac + + # Check if milestone complete + CURRENT_PHASE=$(gsd headless query | jq -r '.state.phase') + if [ "$CURRENT_PHASE" = "complete" ]; then + TOTAL_COST=$(gsd headless query | jq -r '.cost.total') + echo "Milestone complete. Total cost: \$$TOTAL_COST" + break + fi + + # Budget check + TOTAL_COST=$(gsd headless query | jq -r '.cost.total') + OVER=$(echo "$TOTAL_COST > $MAX_BUDGET" | bc -l) + if [ "$OVER" = "1" ]; then + echo "Budget limit (\$$MAX_BUDGET) exceeded at \$$TOTAL_COST" + gsd headless stop + break + fi + + # Progress report + PROGRESS=$(gsd headless query | jq -r '"\(.state.progress.tasks.done)/\(.state.progress.tasks.total) tasks"') + echo "Step done ($STATUS). Phase: $CURRENT_PHASE, Progress: $PROGRESS, Cost: \$$TOTAL_COST" +done +``` + +## Step-by-Step with Spec Creation + +Complete flow from idea to working code with full control: + +```bash +# 1. Setup +PROJECT_DIR="/tmp/my-project" +mkdir -p "$PROJECT_DIR" && cd "$PROJECT_DIR" && git init 2>/dev/null + +# 2. Write spec +cat > spec.md << 'SPEC' +[Your spec here] +SPEC + +# 3. Create the milestone (planning only, no execution) +RESULT=$(gsd headless --output-format json --context spec.md new-milestone 2>/dev/null) +EXIT=$? + +if [ $EXIT -ne 0 ]; then + echo "Milestone creation failed" + echo "$RESULT" | jq . + exit 1 +fi + +echo "Milestone created. Starting execution..." + +# 4. Execute step-by-step +STEP=0 +while true; do + STEP=$((STEP + 1)) + RESULT=$(gsd headless --output-format json next 2>/dev/null) + EXIT=$? + + [ $EXIT -ne 0 ] && break + + PHASE=$(gsd headless query | jq -r '.state.phase') + COST=$(gsd headless query | jq -r '.cost.total') + + echo "Step $STEP complete. Phase: $PHASE, Cost: \$$COST" + + [ "$PHASE" = "complete" ] && break +done + +echo "Build finished in $STEP steps" +``` + +## Intervention Patterns + +### Steer mid-execution + +If you detect the build going in the wrong direction: + +```bash +# Check what's happening +gsd headless query | jq '{phase: .state.phase, task: .state.activeTask}' + +# Redirect +gsd headless steer "Use SQLite instead of PostgreSQL for storage" + +# Continue +gsd headless --output-format json next 2>/dev/null +``` + +### Skip a stuck unit + +```bash +gsd headless skip +gsd headless --output-format json next 2>/dev/null +``` + +### Undo last completed unit + +```bash +gsd headless undo --force +gsd headless --output-format json next 2>/dev/null +``` + +### Force a specific phase + +```bash +gsd headless dispatch replan # Re-plan the current slice +gsd headless dispatch execute # Skip to execution +gsd headless dispatch uat # Jump to user acceptance testing +``` diff --git a/packages/daemon/src/commands.ts b/packages/daemon/src/commands.ts new file mode 100644 index 000000000..515b56728 --- /dev/null +++ b/packages/daemon/src/commands.ts @@ -0,0 +1,95 @@ +/** + * Slash command definitions, guild-scoped registration, and status formatting. + * + * Commands are registered per-guild (not globally) for instant availability. + * Registration failures are non-fatal — the bot continues without slash commands. + */ + +import { + SlashCommandBuilder, + REST, + Routes, + type RESTPostAPIChatInputApplicationCommandsJSONBody, +} from 'discord.js'; +import type { ManagedSession } from './types.js'; +import type { Logger } from './logger.js'; + +// --------------------------------------------------------------------------- +// Command definitions +// --------------------------------------------------------------------------- + +/** + * Build the array of slash command JSON payloads for guild registration. + */ +export function buildCommands(): RESTPostAPIChatInputApplicationCommandsJSONBody[] { + return [ + new SlashCommandBuilder() + .setName('gsd-status') + .setDescription('Show status of all active GSD sessions') + .toJSON(), + new SlashCommandBuilder() + .setName('gsd-start') + .setDescription('Start a new GSD session') + .toJSON(), + new SlashCommandBuilder() + .setName('gsd-stop') + .setDescription('Stop a running GSD session') + .toJSON(), + ]; +} + +// --------------------------------------------------------------------------- +// Guild-scoped registration +// --------------------------------------------------------------------------- + +/** + * Register slash commands for a specific guild via PUT. + * Non-fatal: logs errors and returns false on failure. + */ +export async function registerGuildCommands( + rest: REST, + clientId: string, + guildId: string, + commands: RESTPostAPIChatInputApplicationCommandsJSONBody[], + logger?: Logger, +): Promise { + try { + await rest.put( + Routes.applicationGuildCommands(clientId, guildId), + { body: commands }, + ); + logger?.info('commands registered', { count: commands.length, guildId }); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger?.warn('command registration failed', { + guildId, + error: message, + }); + return false; + } +} + +// --------------------------------------------------------------------------- +// Status formatting +// --------------------------------------------------------------------------- + +/** + * Format session list for /gsd-status reply. + * Shows projectName, status, duration, and cost for each session. + * Returns 'No active sessions.' if the array is empty. + */ +export function formatSessionStatus(sessions: ManagedSession[]): string { + if (sessions.length === 0) { + return 'No active sessions.'; + } + + const lines = sessions.map((s) => { + const durationMs = Date.now() - s.startTime; + const durationMin = Math.floor(durationMs / 60_000); + const cost = s.cost.totalCost.toFixed(4); + return `• **${s.projectName}** — ${s.status} (${durationMin}m, $${cost})`; + }); + + return lines.join('\n'); +} diff --git a/packages/daemon/src/discord-bot.test.ts b/packages/daemon/src/discord-bot.test.ts index c9ddb938c..156568cdb 100644 --- a/packages/daemon/src/discord-bot.test.ts +++ b/packages/daemon/src/discord-bot.test.ts @@ -7,9 +7,10 @@ import { randomUUID } from 'node:crypto'; import { ChannelType } from 'discord.js'; import { isAuthorized, validateDiscordConfig } from './discord-bot.js'; import { sanitizeChannelName, ChannelManager } from './channel-manager.js'; +import { buildCommands, formatSessionStatus } from './commands.js'; import { Daemon } from './daemon.js'; import { Logger } from './logger.js'; -import type { DaemonConfig, LogEntry } from './types.js'; +import type { DaemonConfig, LogEntry, ManagedSession } from './types.js'; // ---------- helpers ---------- @@ -433,3 +434,141 @@ describe('ChannelManager', () => { assert.equal(cat.name, 'Custom Category'); }); }); + +// ---------- buildCommands ---------- + +describe('buildCommands', () => { + it('returns array with correct command names', () => { + const commands = buildCommands(); + assert.equal(commands.length, 3); + const names = commands.map((c) => c.name); + assert.ok(names.includes('gsd-status'), 'should include gsd-status'); + assert.ok(names.includes('gsd-start'), 'should include gsd-start'); + assert.ok(names.includes('gsd-stop'), 'should include gsd-stop'); + }); + + it('each command has a description', () => { + const commands = buildCommands(); + for (const cmd of commands) { + assert.ok(cmd.description, `command ${cmd.name} should have a description`); + assert.ok(cmd.description.length > 0, `command ${cmd.name} description should be non-empty`); + } + }); +}); + +// ---------- formatSessionStatus ---------- + +describe('formatSessionStatus', () => { + function mockSession(overrides: Partial = {}): ManagedSession { + return { + sessionId: 'sess-1', + projectDir: '/home/user/project', + projectName: 'project', + status: 'running', + client: {} as any, + events: [], + pendingBlocker: null, + cost: { totalCost: 0.1234, tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }, + startTime: Date.now() - 120_000, // 2 minutes ago + ...overrides, + }; + } + + it('returns "No active sessions." for empty array', () => { + assert.equal(formatSessionStatus([]), 'No active sessions.'); + }); + + it('formats single session with project name and status', () => { + const result = formatSessionStatus([mockSession()]); + assert.ok(result.includes('project'), 'should contain project name'); + assert.ok(result.includes('running'), 'should contain status'); + assert.ok(result.includes('$'), 'should contain cost'); + }); + + it('formats multiple sessions on separate lines', () => { + const sessions = [ + mockSession({ projectName: 'alpha', status: 'running' }), + mockSession({ projectName: 'beta', status: 'blocked' }), + ]; + const result = formatSessionStatus(sessions); + assert.ok(result.includes('alpha'), 'should contain first project'); + assert.ok(result.includes('beta'), 'should contain second project'); + const lines = result.split('\n'); + assert.equal(lines.length, 2, 'should have one line per session'); + }); + + it('formats 5 sessions correctly', () => { + const sessions = Array.from({ length: 5 }, (_, i) => + mockSession({ projectName: `proj-${i}`, status: i % 2 === 0 ? 'running' : 'completed' }), + ); + const result = formatSessionStatus(sessions); + const lines = result.split('\n'); + assert.equal(lines.length, 5); + for (let i = 0; i < 5; i++) { + assert.ok(lines[i].includes(`proj-${i}`)); + } + }); +}); + +// ---------- Command dispatch (mock interaction) ---------- + +describe('command dispatch', () => { + // Minimal mock of a ChatInputCommandInteraction + function mockInteraction(commandName: string, userId: string = 'owner-1') { + let replied = false; + let replyContent = ''; + + return { + user: { id: userId }, + type: 2, // InteractionType.ApplicationCommand + isChatInputCommand: () => true, + commandName, + reply: async (opts: { content: string; ephemeral?: boolean }) => { + replied = true; + replyContent = opts.content; + }, + _getReplied: () => replied, + _getReplyContent: () => replyContent, + }; + } + + // Minimal mock of a non-command interaction + function mockNonCommandInteraction(userId: string = 'owner-1') { + let replied = false; + return { + user: { id: userId }, + type: 3, // InteractionType.MessageComponent + isChatInputCommand: () => false, + _getReplied: () => replied, + }; + } + + // We can't easily test through DiscordBot.handleInteraction since it's private. + // Instead, test the pure functions that the handler calls, and test auth guard + // behavior via the mock interaction flow. + // The command routing logic is tested indirectly through integration of the + // pure helpers (buildCommands, formatSessionStatus, isAuthorized). + + it('gsd-status with no sessions produces empty message', () => { + // Tests the formatSessionStatus path that /gsd-status calls + const result = formatSessionStatus([]); + assert.equal(result, 'No active sessions.'); + }); + + it('unknown command name is not in buildCommands list', () => { + const commands = buildCommands(); + const names = commands.map((c) => c.name); + assert.ok(!names.includes('gsd-unknown'), 'unknown should not be in command list'); + }); + + it('auth guard rejects non-owner on interaction', () => { + // Simulates the first check in handleInteraction + const authorized = isAuthorized('intruder-999', 'owner-1'); + assert.equal(authorized, false); + }); + + it('auth guard accepts owner on interaction', () => { + const authorized = isAuthorized('owner-1', 'owner-1'); + assert.equal(authorized, true); + }); +}); diff --git a/packages/daemon/src/discord-bot.ts b/packages/daemon/src/discord-bot.ts index f7e6ca7c9..5a1614f71 100644 --- a/packages/daemon/src/discord-bot.ts +++ b/packages/daemon/src/discord-bot.ts @@ -9,11 +9,15 @@ import { Client, GatewayIntentBits, + REST, type Interaction, + type Guild, } from 'discord.js'; import type { DaemonConfig } from './types.js'; import type { Logger } from './logger.js'; import type { SessionManager } from './session-manager.js'; +import { ChannelManager } from './channel-manager.js'; +import { buildCommands, registerGuildCommands, formatSessionStatus } from './commands.js'; // --------------------------------------------------------------------------- // Pure helpers — exported for testability @@ -62,6 +66,7 @@ export interface DiscordBotOptions { export class DiscordBot { private client: Client | null = null; private destroyed = false; + private channelManager: ChannelManager | null = null; private readonly config: NonNullable; private readonly logger: Logger; @@ -92,6 +97,22 @@ export class DiscordBot { username: readyClient.user.tag, guilds: guildNames, }); + + // Register slash commands for the configured guild + const rest = new REST({ version: '10' }).setToken(this.config.token); + const commands = buildCommands(); + registerGuildCommands( + rest, + readyClient.user.id, + this.config.guild_id, + commands, + this.logger, + ).catch((err) => { + // Should not reach here — registerGuildCommands catches internally + this.logger.warn('unexpected command registration error', { + error: err instanceof Error ? err.message : String(err), + }); + }); }); client.on('interactionCreate', (interaction: Interaction) => { @@ -128,6 +149,28 @@ export class DiscordBot { } } + // --------------------------------------------------------------------------- + // Public accessors + // --------------------------------------------------------------------------- + + /** + * Lazily create a ChannelManager from the configured guild. + * Returns null if the client isn't ready or the guild isn't found. + */ + getChannelManager(): ChannelManager | null { + if (this.channelManager) return this.channelManager; + if (!this.client?.isReady()) return null; + + const guild = this.client.guilds.cache.get(this.config.guild_id); + if (!guild) { + this.logger.warn('guild not found for channel manager', { guildId: this.config.guild_id }); + return null; + } + + this.channelManager = new ChannelManager({ guild, logger: this.logger }); + return this.channelManager; + } + // --------------------------------------------------------------------------- // Private: interaction handling // --------------------------------------------------------------------------- @@ -138,11 +181,44 @@ export class DiscordBot { return; } - // Authorized — delegate to command handler (stub for T03 slash commands) - // For now, just log the interaction type for observability - this.logger.debug('interaction received', { - type: interaction.type, - userId: interaction.user.id, - }); + // Only handle chat input (slash) commands + if (!interaction.isChatInputCommand()) { + this.logger.debug('non-command interaction', { + type: interaction.type, + userId: interaction.user.id, + }); + return; + } + + const { commandName } = interaction; + this.logger.info('command handled', { commandName, userId: interaction.user.id }); + + switch (commandName) { + case 'gsd-status': { + const sessions = this.sessionManager.getAllSessions(); + const content = formatSessionStatus(sessions); + interaction.reply({ content, ephemeral: true }).catch((err) => { + this.logger.warn('gsd-status reply failed', { + error: err instanceof Error ? err.message : String(err), + }); + }); + break; + } + case 'gsd-start': + case 'gsd-stop': + interaction.reply({ content: 'Coming soon — use #gsd-control', ephemeral: true }).catch((err) => { + this.logger.warn(`${commandName} reply failed`, { + error: err instanceof Error ? err.message : String(err), + }); + }); + break; + default: + interaction.reply({ content: 'Unknown command', ephemeral: true }).catch((err) => { + this.logger.warn('unknown command reply failed', { + error: err instanceof Error ? err.message : String(err), + }); + }); + break; + } } } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 11402c555..571d18da6 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -19,3 +19,6 @@ export { scanForProjects } from './project-scanner.js'; export { SessionManager } from './session-manager.js'; export { DiscordBot, isAuthorized, validateDiscordConfig } from './discord-bot.js'; export type { DiscordBotOptions } from './discord-bot.js'; +export { ChannelManager, sanitizeChannelName } from './channel-manager.js'; +export type { ChannelManagerOptions } from './channel-manager.js'; +export { buildCommands, formatSessionStatus, registerGuildCommands } from './commands.js';