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:
parent
d13885a54e
commit
b5adaf2d9f
8 changed files with 867 additions and 7 deletions
20
gsd-orchestrator/templates/spec.md
Normal file
20
gsd-orchestrator/templates/spec.md
Normal 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]
|
||||
184
gsd-orchestrator/workflows/build-from-spec.md
Normal file
184
gsd-orchestrator/workflows/build-from-spec.md
Normal 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
|
||||
```
|
||||
187
gsd-orchestrator/workflows/monitor-and-poll.md
Normal file
187
gsd-orchestrator/workflows/monitor-and-poll.md
Normal 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
|
||||
```
|
||||
156
gsd-orchestrator/workflows/step-by-step.md
Normal file
156
gsd-orchestrator/workflows/step-by-step.md
Normal 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
|
||||
```
|
||||
95
packages/daemon/src/commands.ts
Normal file
95
packages/daemon/src/commands.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue