diff --git a/gsd-orchestrator/SKILL.md b/gsd-orchestrator/SKILL.md
index 48e044b8c..6d828cde4 100644
--- a/gsd-orchestrator/SKILL.md
+++ b/gsd-orchestrator/SKILL.md
@@ -1,13 +1,11 @@
---
name: gsd-orchestrator
description: >
- Orchestrate GSD (Get Shit Done) projects via subprocess execution.
- Use when an agent needs to create milestones from specs, execute software
- development workflows, monitor task progress, poll status, handle blockers,
- or track costs. Triggers on requests to "run gsd", "create milestone",
- "execute project", "check gsd status", "orchestrate development",
- "run headless workflow", or any programmatic interaction with the GSD
- project management system.
+ Build software products autonomously via GSD headless mode. Handles the full
+ lifecycle: write a spec, launch a build, poll for completion, handle blockers,
+ track costs, and verify the result. Use when asked to "build something",
+ "create a project", "run gsd", "check build status", or any task that
+ requires autonomous software development via subprocess.
metadata:
openclaw:
requires:
@@ -18,357 +16,114 @@ metadata:
bins: [gsd]
---
-# GSD Orchestrator
+
+You are an autonomous agent that builds software by orchestrating GSD as a subprocess.
+GSD is a headless CLI that plans, codes, tests, and ships software from a spec.
+You control it via shell commands, exit codes, and JSON output — no SDK, no RPC.
+
-Run GSD commands as subprocesses via `gsd headless`. No SDK, no RPC — just shell exec, exit codes, and JSON on stdout.
+
+GSD headless is a subprocess you launch and monitor. Think of it like a junior developer
+you hand a spec to:
-## Quick Start
+1. You write the spec (what to build)
+2. You launch the build (`gsd headless ... new-milestone --context spec.md --auto`)
+3. You wait for it to finish (exit code tells you the outcome)
+4. You check the result (query state, inspect files, verify deliverables)
+5. If blocked, you intervene (steer, supply answers, or escalate)
+The subprocess handles all planning, coding, testing, and git commits internally.
+You never write application code yourself — GSD does that.
+
+
+
+- **Flags before command.** `gsd headless [--flags] [command] [args]`. Flags after the command are ignored.
+- **Redirect stderr.** JSON output goes to stdout. Progress goes to stderr. Always `2>/dev/null` when parsing JSON.
+- **Check exit codes.** 0=success, 1=error, 10=blocked (needs you), 11=cancelled.
+- **Use `query` to poll.** Instant (~50ms), no LLM cost. Use it between steps, not `auto` for status.
+- **Budget awareness.** Track `cost.total` from query results. Set limits before launching long runs.
+- **One project directory per build.** Each GSD project needs its own directory with a `.gsd/` folder.
+
+
+
+Route based on what you need to do:
+
+**Build something from scratch:**
+Read `workflows/build-from-spec.md` — write spec, init directory, launch, monitor, verify.
+
+**Check on a running or completed build:**
+Read `workflows/monitor-and-poll.md` — query state, interpret phases, handle blockers.
+
+**Execute with fine-grained control:**
+Read `workflows/step-by-step.md` — run one unit at a time with decision points.
+
+**Understand the JSON output:**
+Read `references/json-result.md` — field reference for HeadlessJsonResult.
+
+**Pre-supply answers or secrets:**
+Read `references/answer-injection.md` — answer file schema and injection mechanism.
+
+**Look up a specific command:**
+Read `references/commands.md` — full command reference with flags and examples.
+
+
+
+
+**Launch a full build (spec to working code):**
```bash
-# Install GSD globally
-npm install -g gsd-pi
-
-# Verify installation
-gsd --version
-
-# Create a milestone from a spec and execute it
-gsd headless --output-format json new-milestone --context spec.md --auto
+mkdir -p /tmp/my-project && cd /tmp/my-project && git init
+cat > spec.md << 'EOF'
+# Your Product Spec Here
+Build a ...
+EOF
+gsd headless --output-format json --context spec.md new-milestone --auto 2>/dev/null
```
-## Command Syntax
-
+**Check project state (instant, free):**
```bash
-gsd headless [flags] [command] [args...]
+cd /path/to/project
+gsd headless query | jq '{phase: .state.phase, progress: .state.progress, cost: .cost.total}'
```
-Default command is `auto` (run all queued units).
-
-### Flags
-
-| Flag | Description |
-|------|-------------|
-| `--output-format ` | Output format: `text` (default), `json` (structured result at exit), `stream-json` (JSONL events) |
-| `--json` | Alias for `--output-format stream-json` — JSONL event stream to stdout |
-| `--bare` | Minimal context: skip CLAUDE.md, AGENTS.md, user settings, user skills. Use for CI/ecosystem runs. |
-| `--resume ` | Resume a prior headless session by its session ID |
-| `--timeout N` | Overall timeout in ms (default: 300000) |
-| `--model ID` | Override LLM model |
-| `--supervised` | Forward interactive UI requests to orchestrator via stdout/stdin |
-| `--response-timeout N` | Timeout (ms) for orchestrator response in supervised mode (default: 30000) |
-| `--answers ` | Pre-supply answers and secrets from JSON file |
-| `--events ` | Filter JSONL output to specific event types (comma-separated, implies `--json`) |
-| `--verbose` | Show tool calls in progress output |
-
-### Exit Codes
-
-| Code | Meaning | Constant |
-|------|---------|----------|
-| `0` | Success — unit/milestone completed | `EXIT_SUCCESS` |
-| `1` | Error or timeout | `EXIT_ERROR` |
-| `10` | Blocked — needs human intervention | `EXIT_BLOCKED` |
-| `11` | Cancelled by user or orchestrator | `EXIT_CANCELLED` |
-
-These codes are stable and suitable for CI pipelines and orchestrator logic.
-
-### Output Formats
-
-| Format | Behavior |
-|--------|----------|
-| `text` | Human-readable progress on stderr. Default. |
-| `json` | Collect events silently. Emit a single `HeadlessJsonResult` JSON object to stdout at exit. |
-| `stream-json` | Stream JSONL events to stdout in real time (same as `--json`). |
-
-Use `--output-format json` when you need a structured result for decision-making. See [references/json-result.md](references/json-result.md) for the full field reference.
-
-## Core Workflows
-
-### 1. Create + Execute a Milestone (end-to-end)
-
+**Resume work on an existing project:**
```bash
-gsd headless --output-format json new-milestone --context spec.md --auto
+cd /path/to/project
+gsd headless --output-format json auto 2>/dev/null
```
-Reads a spec file, bootstraps `.gsd/`, creates the milestone, then chains into auto-mode executing all phases (discuss → research → plan → execute → summarize → complete). The JSON result is emitted on stdout at exit.
-
-Extra flags for `new-milestone`:
-- `--context ` — path to spec/PRD file (use `-` for stdin)
-- `--context-text ` — inline specification text
-- `--auto` — start auto-mode after milestone creation
-- `--verbose` — show tool calls in progress output
-
-```bash
-# From stdin
-cat spec.md | gsd headless --output-format json new-milestone --context - --auto
-
-# Inline text
-gsd headless new-milestone --context-text "Build a REST API for user management" --auto
-```
-
-### 2. Run All Queued Work
-
-```bash
-gsd headless --output-format json auto
-```
-
-Loop through all pending units until milestone complete or blocked.
-
-### 3. Run One Unit (step-by-step)
-
-```bash
-gsd headless --output-format json next
-```
-
-Execute exactly one unit (task/slice/milestone step), then exit. This is the recommended pattern for orchestrators that need control between steps.
-
-### 4. Instant State Snapshot (no LLM)
-
-```bash
-gsd headless query
-```
-
-Returns a single JSON object with the full project snapshot — no LLM session, instant (~50ms). **This is the recommended way for orchestrators to inspect state.**
-
-```json
-{
- "state": {
- "phase": "executing",
- "activeMilestone": { "id": "M001", "title": "..." },
- "activeSlice": { "id": "S01", "title": "..." },
- "progress": { "completed": 3, "total": 7 },
- "registry": [...]
- },
- "next": { "action": "dispatch", "unitType": "execute-task", "unitId": "M001/S01/T01" },
- "cost": { "workers": [{ "milestoneId": "M001", "cost": 1.50 }], "total": 1.50 }
-}
-```
-
-### 5. Dispatch Specific Phase
-
-```bash
-gsd headless dispatch research|plan|execute|complete|reassess|uat|replan
-```
-
-Force-route to a specific phase, bypassing normal state-machine routing.
-
-### 6. Resume a Session
-
-```bash
-gsd headless --resume auto
-```
-
-Resume a prior headless session. The session ID is available in the `HeadlessJsonResult.sessionId` field from a previous `--output-format json` run.
-
-## Orchestrator Patterns
-
-### Parse the Structured JSON Result
-
-When using `--output-format json`, the process emits a single `HeadlessJsonResult` on stdout at exit. Parse it for decision-making:
-
+**Run one step at a time:**
```bash
RESULT=$(gsd headless --output-format json next 2>/dev/null)
-EXIT=$?
-
-STATUS=$(echo "$RESULT" | jq -r '.status')
-COST=$(echo "$RESULT" | jq -r '.cost.total')
-PHASE=$(echo "$RESULT" | jq -r '.phase')
-NEXT=$(echo "$RESULT" | jq -r '.nextAction')
-SESSION_ID=$(echo "$RESULT" | jq -r '.sessionId')
-
-echo "Status: $STATUS, Cost: \$${COST}, Phase: $PHASE, Next: $NEXT"
+echo "$RESULT" | jq '{status: .status, phase: .phase, cost: .cost.total}'
```
-See [references/json-result.md](references/json-result.md) for the full field reference.
+
-### Blocker Detection and Handling
-
-Exit code `10` means the execution hit a blocker requiring human intervention:
-
-```bash
-gsd headless --output-format json next 2>/dev/null
-EXIT=$?
-
-if [ $EXIT -eq 10 ]; then
- # Inspect the blocker
- BLOCKER=$(gsd headless query | jq '.state.phase')
- echo "Blocked: $BLOCKER"
-
- # Option 1: Use --supervised mode to handle interactively
- gsd headless --supervised auto
-
- # Option 2: Pre-supply answers to resolve the blocker
- gsd headless --answers blocker-answers.json auto
-
- # Option 3: Steer the plan to work around it
- gsd headless steer "Skip the blocked dependency, use mock instead"
-fi
-```
-
-### Cost Tracking and Budget Enforcement
-
-```bash
-MAX_BUDGET=10.00
-
-RESULT=$(gsd headless --output-format json next 2>/dev/null)
-COST=$(echo "$RESULT" | jq -r '.cost.total')
-
-# Check cumulative cost via query (includes all workers)
-TOTAL_COST=$(gsd headless query | jq -r '.cost.total')
-
-if (( $(echo "$TOTAL_COST > $MAX_BUDGET" | bc -l) )); then
- echo "Budget exceeded: \$$TOTAL_COST > \$$MAX_BUDGET"
- gsd headless stop
- exit 1
-fi
-```
-
-### Step-by-Step with Monitoring
-
-The recommended pattern for full control. Run one unit at a time, inspect state between steps:
-
-```bash
-while true; do
- RESULT=$(gsd headless --output-format json next 2>/dev/null)
- EXIT=$?
-
- STATUS=$(echo "$RESULT" | jq -r '.status')
- COST=$(echo "$RESULT" | jq -r '.cost.total')
-
- echo "Exit: $EXIT, Status: $STATUS, Cost: \$$COST"
-
- # Handle terminal states
- [ $EXIT -eq 0 ] || break
-
- # Check if milestone is complete
- PHASE=$(gsd headless query | jq -r '.state.phase')
- [ "$PHASE" = "complete" ] && echo "Milestone complete" && break
-
- # Budget check
- TOTAL=$(gsd headless query | jq -r '.cost.total')
- if (( $(echo "$TOTAL > 20.00" | bc -l) )); then
- echo "Budget limit reached"
- break
- fi
-done
-```
-
-### Poll-and-React Loop
-
-Lightweight pattern using only the instant `query` command:
-
-```bash
-PHASE=$(gsd headless query | jq -r '.state.phase')
-NEXT_ACTION=$(gsd headless query | jq -r '.next.action')
-
-case "$PHASE" in
- complete) echo "Done" ;;
- blocked) echo "Needs intervention — exit code 10" ;;
- *) [ "$NEXT_ACTION" = "dispatch" ] && gsd headless next ;;
-esac
-```
-
-### CI/Ecosystem Mode
-
-Use `--bare` to skip user-specific configuration for deterministic CI runs:
-
-```bash
-gsd headless --bare --output-format json auto 2>/dev/null
-```
-
-This skips CLAUDE.md, AGENTS.md, user settings, and user skills. Bundled GSD extensions and `.gsd/` state are still loaded (they're required for GSD to function).
-
-### JSONL Event Stream
-
-Use `--json` (or `--output-format stream-json`) for real-time events:
-
-```bash
-gsd headless --json auto 2>/dev/null | while read -r line; do
- TYPE=$(echo "$line" | jq -r '.type')
- case "$TYPE" in
- tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;;
- extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;;
- agent_end) echo "Session ended" ;;
- esac
-done
-```
-
-### Filtered Event Stream
-
-Use `--events` to receive only specific event types:
-
-```bash
-# Only phase-relevant events
-gsd headless --events agent_end,extension_ui_request auto 2>/dev/null
-
-# Only tool execution events
-gsd headless --events tool_execution_start,tool_execution_end auto
-```
-
-Available event types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`, `tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`, `message_update`, `turn_start`, `turn_end`.
-
-## Answer Injection
-
-Pre-supply answers and secrets for fully autonomous headless runs:
-
-```bash
-gsd headless --answers answers.json auto
-```
-
-Answer file schema:
-```json
-{
- "questions": { "question_id": "selected_option" },
- "secrets": { "API_KEY": "sk-..." },
- "defaults": { "strategy": "first_option" }
-}
-```
-
-- **questions** — question ID → answer (string for single-select, string[] for multi-select)
-- **secrets** — env var → value, injected into child process environment
-- **defaults.strategy** — `"first_option"` (default) or `"cancel"` for unmatched questions
-
-See [references/answer-injection.md](references/answer-injection.md) for the full mechanism.
-
-## GSD Project Structure
-
-All state lives in `.gsd/` as markdown files (version-controllable):
+
+| Code | Meaning | Your action |
+|------|---------|-------------|
+| `0` | Success | Check deliverables, verify output, report completion |
+| `1` | Error or timeout | Inspect stderr, check `.gsd/STATE.md`, retry or escalate |
+| `10` | Blocked | Query state for blocker details, steer around it or escalate to human |
+| `11` | Cancelled | Process was interrupted — resume with `--resume ` or restart |
+
+
+GSD creates and manages all state in `.gsd/`:
```
.gsd/
- PROJECT.md
- REQUIREMENTS.md
- DECISIONS.md
- KNOWLEDGE.md
- STATE.md
+ PROJECT.md # What this project is
+ REQUIREMENTS.md # Capability contract
+ DECISIONS.md # Architectural decisions (append-only)
+ STATE.md # Current phase and next action
milestones/
- M001/
- M001-CONTEXT.md # Requirements, scope, decisions
- M001-ROADMAP.md # Slices with tasks, dependencies, checkboxes
- M001-SUMMARY.md # Completion summary
- slices/
- S01/
- S01-PLAN.md # Task list
- S01-SUMMARY.md # Slice summary
- tasks/
- T01-PLAN.md # Individual task spec
- T01-SUMMARY.md # Task completion summary
+ M001-xxxxx/
+ M001-xxxxx-CONTEXT.md # Scope, constraints, assumptions
+ M001-xxxxx-ROADMAP.md # Slices with checkboxes
+ slices/S01/
+ S01-PLAN.md # Tasks
+ tasks/T01-PLAN.md # Individual task spec
```
-State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are the source of truth for completion.
-
-## All Commands
-
-See [references/commands.md](references/commands.md) for the complete reference.
-
-| Command | Purpose |
-|---------|---------|
-| `auto` | Run all queued units (default) |
-| `next` | Run one unit |
-| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM) |
-| `new-milestone` | Create milestone from spec |
-| `dispatch ` | Force specific phase |
-| `stop` / `pause` | Control auto-mode |
-| `steer ` | Hard-steer plan mid-execution |
-| `skip` / `undo` | Unit control |
-| `queue` | Queue/reorder milestones |
-| `history` | View execution history |
-| `doctor` | Health check + auto-fix |
+You never need to edit these files. GSD manages them. But you can read them to understand progress.
+
diff --git a/packages/daemon/src/channel-manager.ts b/packages/daemon/src/channel-manager.ts
new file mode 100644
index 000000000..b0ae1604c
--- /dev/null
+++ b/packages/daemon/src/channel-manager.ts
@@ -0,0 +1,223 @@
+/**
+ * ChannelManager — manages per-project Discord text channels under a
+ * 'GSD Projects' category, with archive support.
+ *
+ * Pure helper `sanitizeChannelName` exported separately for testability.
+ */
+
+import {
+ ChannelType,
+ PermissionFlagsBits,
+ type Guild,
+ type CategoryChannel,
+ type TextChannel,
+ type GuildBasedChannel,
+} from 'discord.js';
+import type { Logger } from './logger.js';
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const DEFAULT_CATEGORY_NAME = 'GSD Projects';
+const ARCHIVE_CATEGORY_NAME = 'GSD Archive';
+const CHANNEL_PREFIX = 'gsd-';
+const MAX_CHANNEL_NAME_LENGTH = 100; // Discord's limit
+
+// ---------------------------------------------------------------------------
+// Pure helpers — exported for testability
+// ---------------------------------------------------------------------------
+
+/**
+ * Sanitize a project directory path into a valid Discord channel name.
+ *
+ * - Takes the basename of the path
+ * - Lowercases
+ * - Replaces non-alphanumeric (except hyphens) with hyphens
+ * - Collapses consecutive hyphens
+ * - Trims leading/trailing hyphens
+ * - Prefixes with 'gsd-'
+ * - Caps total length at 100 chars (Discord limit)
+ *
+ * Returns 'gsd-unnamed' for empty/whitespace-only inputs.
+ */
+export function sanitizeChannelName(projectDir: string): string {
+ // Extract basename — handle both forward and back slashes
+ const parts = projectDir.replace(/\\/g, '/').split('/');
+ let basename = parts[parts.length - 1] ?? '';
+
+ // Trim whitespace
+ basename = basename.trim();
+
+ // Fallback for empty basename
+ if (!basename) {
+ return 'gsd-unnamed';
+ }
+
+ // Lowercase
+ let name = basename.toLowerCase();
+
+ // Replace non-alphanumeric (except hyphens) with hyphens
+ name = name.replace(/[^a-z0-9-]/g, '-');
+
+ // Collapse consecutive hyphens
+ name = name.replace(/-{2,}/g, '-');
+
+ // Trim leading/trailing hyphens
+ name = name.replace(/^-+|-+$/g, '');
+
+ // Fallback if nothing remains after sanitization
+ if (!name) {
+ return 'gsd-unnamed';
+ }
+
+ // Prefix
+ const prefixed = `${CHANNEL_PREFIX}${name}`;
+
+ // Cap at max length
+ if (prefixed.length > MAX_CHANNEL_NAME_LENGTH) {
+ // Truncate and remove any trailing hyphen from the cut
+ return prefixed.slice(0, MAX_CHANNEL_NAME_LENGTH).replace(/-+$/, '');
+ }
+
+ return prefixed;
+}
+
+// ---------------------------------------------------------------------------
+// ChannelManager class
+// ---------------------------------------------------------------------------
+
+export interface ChannelManagerOptions {
+ guild: Guild;
+ logger: Logger;
+ categoryName?: string;
+}
+
+export class ChannelManager {
+ private readonly guild: Guild;
+ private readonly logger: Logger;
+ private readonly categoryName: string;
+
+ private categoryCache: CategoryChannel | null = null;
+ private archiveCategoryCache: CategoryChannel | null = null;
+
+ constructor(opts: ChannelManagerOptions) {
+ this.guild = opts.guild;
+ this.logger = opts.logger;
+ this.categoryName = opts.categoryName ?? DEFAULT_CATEGORY_NAME;
+ }
+
+ /**
+ * Find or create the project category channel.
+ * Caches the result — subsequent calls return the cached category.
+ */
+ async resolveCategory(): Promise {
+ if (this.categoryCache) {
+ return this.categoryCache;
+ }
+
+ const existing = this.findCategoryByName(this.categoryName);
+ if (existing) {
+ this.categoryCache = existing;
+ this.logger.debug('category resolved from cache', { name: this.categoryName, id: existing.id });
+ return existing;
+ }
+
+ // Create the category
+ const created = await this.guild.channels.create({
+ name: this.categoryName,
+ type: ChannelType.GuildCategory,
+ });
+
+ this.categoryCache = created as CategoryChannel;
+ this.logger.info('category created', { name: this.categoryName, id: created.id });
+ return this.categoryCache;
+ }
+
+ /**
+ * Create a text channel for a project under the GSD Projects category.
+ * Channel name is derived from the project directory path.
+ */
+ async createProjectChannel(projectDir: string): Promise {
+ const name = sanitizeChannelName(projectDir);
+ const category = await this.resolveCategory();
+
+ const channel = await this.guild.channels.create({
+ name,
+ type: ChannelType.GuildText,
+ parent: category.id,
+ });
+
+ this.logger.info('project channel created', {
+ name,
+ channelId: channel.id,
+ categoryId: category.id,
+ projectDir,
+ });
+
+ return channel as TextChannel;
+ }
+
+ /**
+ * Archive a channel by moving it to the 'GSD Archive' category and
+ * setting permission overwrite to deny ViewChannel for @everyone.
+ */
+ async archiveChannel(channelId: string): Promise {
+ const archive = await this.resolveArchiveCategory();
+
+ const channel = this.guild.channels.cache.get(channelId);
+ if (!channel) {
+ this.logger.warn('archive target not found', { channelId });
+ return;
+ }
+
+ if (!('edit' in channel) || typeof channel.edit !== 'function') {
+ this.logger.warn('archive target is not editable', { channelId, type: channel.type });
+ return;
+ }
+
+ await channel.edit({
+ parent: archive.id,
+ permissionOverwrites: [
+ {
+ id: this.guild.id, // @everyone role ID matches guild ID
+ deny: [PermissionFlagsBits.ViewChannel],
+ },
+ ],
+ });
+
+ this.logger.info('channel archived', { channelId, archiveCategoryId: archive.id });
+ }
+
+ // ---------------------------------------------------------------------------
+ // Private helpers
+ // ---------------------------------------------------------------------------
+
+ private findCategoryByName(name: string): CategoryChannel | null {
+ const match = this.guild.channels.cache.find(
+ (ch: GuildBasedChannel) => ch.type === ChannelType.GuildCategory && ch.name === name,
+ );
+ return (match as CategoryChannel) ?? null;
+ }
+
+ private async resolveArchiveCategory(): Promise {
+ if (this.archiveCategoryCache) {
+ return this.archiveCategoryCache;
+ }
+
+ const existing = this.findCategoryByName(ARCHIVE_CATEGORY_NAME);
+ if (existing) {
+ this.archiveCategoryCache = existing;
+ return existing;
+ }
+
+ const created = await this.guild.channels.create({
+ name: ARCHIVE_CATEGORY_NAME,
+ type: ChannelType.GuildCategory,
+ });
+
+ this.archiveCategoryCache = created as CategoryChannel;
+ this.logger.info('archive category created', { name: ARCHIVE_CATEGORY_NAME, id: created.id });
+ return this.archiveCategoryCache;
+ }
+}
diff --git a/packages/daemon/src/discord-bot.test.ts b/packages/daemon/src/discord-bot.test.ts
index 3d9c6835e..c9ddb938c 100644
--- a/packages/daemon/src/discord-bot.test.ts
+++ b/packages/daemon/src/discord-bot.test.ts
@@ -4,7 +4,9 @@ import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
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 { Daemon } from './daemon.js';
import { Logger } from './logger.js';
import type { DaemonConfig, LogEntry } from './types.js';
@@ -222,3 +224,212 @@ describe('Daemon + DiscordBot wiring', () => {
assert.ok(!content.includes('bot ready'));
});
});
+
+// ---------- sanitizeChannelName ----------
+
+describe('sanitizeChannelName', () => {
+ it('converts basic path to gsd-prefixed name', () => {
+ assert.equal(sanitizeChannelName('/home/user/my-project'), 'gsd-my-project');
+ });
+
+ it('converts path with special characters to hyphens', () => {
+ assert.equal(sanitizeChannelName('/home/user/My_Cool.Project!v2'), 'gsd-my-cool-project-v2');
+ });
+
+ it('truncates very long names to 100 chars', () => {
+ const longName = 'a'.repeat(200);
+ const result = sanitizeChannelName(`/home/${longName}`);
+ assert.ok(result.length <= 100, `Expected <= 100 chars, got ${result.length}`);
+ assert.ok(result.startsWith('gsd-'));
+ });
+
+ it('cleans leading/trailing dots and underscores', () => {
+ assert.equal(sanitizeChannelName('/home/...___project___...'), 'gsd-project');
+ });
+
+ it('returns gsd-unnamed for empty basename', () => {
+ assert.equal(sanitizeChannelName(''), 'gsd-unnamed');
+ assert.equal(sanitizeChannelName('/'), 'gsd-unnamed');
+ });
+
+ it('returns gsd-unnamed for basename with only special chars', () => {
+ assert.equal(sanitizeChannelName('/home/!!!'), 'gsd-unnamed');
+ });
+
+ it('collapses consecutive hyphens', () => {
+ assert.equal(sanitizeChannelName('/home/a---b---c'), 'gsd-a-b-c');
+ });
+
+ it('handles Windows-style backslash paths', () => {
+ assert.equal(sanitizeChannelName('C:\\Users\\lex\\my-project'), 'gsd-my-project');
+ });
+
+ it('handles name at exact prefix + 96 chars = 100 char limit', () => {
+ // gsd- is 4 chars, so a 96-char basename should produce exactly 100
+ const name96 = 'a'.repeat(96);
+ const result = sanitizeChannelName(`/home/${name96}`);
+ assert.equal(result.length, 100);
+ assert.equal(result, `gsd-${'a'.repeat(96)}`);
+ });
+
+ it('handles whitespace-only basename', () => {
+ assert.equal(sanitizeChannelName('/home/ '), 'gsd-unnamed');
+ });
+});
+
+// ---------- ChannelManager ----------
+
+describe('ChannelManager', () => {
+ // Helper to create a mock Guild with controllable channel cache and create method
+ function createMockGuild() {
+ const channels = new Map();
+ let createCounter = 0;
+
+ const mockGuild = {
+ id: 'guild-123', // @everyone role ID matches guild ID
+ channels: {
+ cache: {
+ get: (id: string) => channels.get(id),
+ find: (fn: (ch: any) => boolean) => {
+ for (const ch of channels.values()) {
+ if (fn(ch)) return ch;
+ }
+ return undefined;
+ },
+ },
+ create: async (opts: { name: string; type: number; parent?: string; permissionOverwrites?: any[] }) => {
+ createCounter++;
+ const id = `chan-${createCounter}`;
+ const ch = {
+ id,
+ name: opts.name,
+ type: opts.type,
+ parentId: opts.parent ?? null,
+ edit: async (editOpts: any) => {
+ // Simulate edit — update parent
+ ch.parentId = editOpts.parent ?? ch.parentId;
+ return ch;
+ },
+ };
+ channels.set(id, ch);
+ return ch;
+ },
+ },
+ _channels: channels, // internal for test inspection
+ _getCreateCount: () => createCounter,
+ };
+
+ return mockGuild;
+ }
+
+ function createMockLogger() {
+ const entries: { level: string; msg: string; data?: any }[] = [];
+ return {
+ debug: (msg: string, data?: any) => entries.push({ level: 'debug', msg, data }),
+ info: (msg: string, data?: any) => entries.push({ level: 'info', msg, data }),
+ warn: (msg: string, data?: any) => entries.push({ level: 'warn', msg, data }),
+ error: (msg: string, data?: any) => entries.push({ level: 'error', msg, data }),
+ entries,
+ close: async () => {},
+ };
+ }
+
+ it('resolveCategory creates category when not found', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ const cat = await mgr.resolveCategory();
+ assert.equal(cat.name, 'GSD Projects');
+ assert.equal(cat.type, ChannelType.GuildCategory);
+ });
+
+ it('resolveCategory returns cached category on second call', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ const cat1 = await mgr.resolveCategory();
+ const cat2 = await mgr.resolveCategory();
+ assert.equal(cat1.id, cat2.id);
+ // Only one create call should have been made
+ assert.equal(guild._getCreateCount(), 1);
+ });
+
+ it('resolveCategory finds existing category by name', async () => {
+ const guild = createMockGuild();
+ // Pre-populate a matching category
+ guild._channels.set('existing-cat', {
+ id: 'existing-cat',
+ name: 'GSD Projects',
+ type: ChannelType.GuildCategory,
+ parentId: null,
+ });
+
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ const cat = await mgr.resolveCategory();
+ assert.equal(cat.id, 'existing-cat');
+ // No create calls — found existing
+ assert.equal(guild._getCreateCount(), 0);
+ });
+
+ it('createProjectChannel creates text channel under category', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ const channel = await mgr.createProjectChannel('/home/user/my-project');
+ assert.equal(channel.name, 'gsd-my-project');
+ assert.equal(channel.type, ChannelType.GuildText);
+ // Category was created first (chan-1), then channel (chan-2)
+ assert.equal(channel.parentId, 'chan-1');
+ });
+
+ it('archiveChannel moves channel to archive category', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ // Create a project channel first
+ const channel = await mgr.createProjectChannel('/home/user/project');
+ const channelId = channel.id;
+
+ // Archive it
+ await mgr.archiveChannel(channelId);
+
+ // The channel should have been edit()-ed with the archive category as parent
+ const archived = guild._channels.get(channelId)!;
+ // Archive category was created as the 3rd channel (chan-3): category(chan-1), text(chan-2), archive(chan-3)
+ assert.equal(archived.parentId, 'chan-3');
+
+ // Verify archive log
+ const archiveLog = logger.entries.find((e) => e.msg === 'channel archived');
+ assert.ok(archiveLog, 'should log channel archived');
+ assert.equal(archiveLog!.data.channelId, channelId);
+ });
+
+ it('archiveChannel warns when channel not found', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({ guild: guild as any, logger: logger as any });
+
+ await mgr.archiveChannel('nonexistent-id');
+ const warnLog = logger.entries.find((e) => e.msg === 'archive target not found');
+ assert.ok(warnLog, 'should warn about missing channel');
+ });
+
+ it('uses custom category name when provided', async () => {
+ const guild = createMockGuild();
+ const logger = createMockLogger();
+ const mgr = new ChannelManager({
+ guild: guild as any,
+ logger: logger as any,
+ categoryName: 'Custom Category',
+ });
+
+ const cat = await mgr.resolveCategory();
+ assert.equal(cat.name, 'Custom Category');
+ });
+});