test: Created commands.ts with slash command definitions and registrati…

- "packages/daemon/src/commands.ts"
- "packages/daemon/src/discord-bot.ts"
- "packages/daemon/src/discord-bot.test.ts"
- "packages/daemon/src/index.ts"

GSD-Task: S03/T03
This commit is contained in:
Lex Christopherson 2026-03-27 14:43:03 -06:00
parent d13885a54e
commit b5adaf2d9f
8 changed files with 867 additions and 7 deletions

View file

@ -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]

View file

@ -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
```

View file

@ -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
```

View file

@ -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
```

View file

@ -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<boolean> {
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');
}

View file

@ -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> = {}): 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);
});
});

View file

@ -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<DaemonConfig['discord']>;
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;
}
}
}

View file

@ -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';