Files
hanarang-rails/docs/discord-setup.md
이랑이 2cadb3e0df feat(sprint-007): 마이그레이션 도구 + 운영 문서 + v0.1.0 릴리즈 준비
Sprint 007 전체 구현 — 마지막 스프린트. 프로젝트 완성:

CLI:
- rails doctor — 환경 헬스체크 (Node/pnpm/git/env/프로젝트 파일)
- rails scaffold [dir] — 신규 프로젝트 .plans/ 구조 생성
- rails migrate from-hanarang-harness <path> — 레거시 아카이브 스캐너
  agents/scripts/workflows 분류 (portable vs deprecated)
  xhigh 참조 경고 등 위험 패턴 감지

Docs (신규 3종):
- docs/migration-guide.md — 레거시 하네스 → rails 단계별 이전 가이드
- docs/operations.md — PM2, health check, 트러블슈팅, DB 유지보수
- docs/discord-setup.md — 봇 생성, DiscordPoster 구현 예시,
  marker 프로토콜 완전 명세

README 대폭 업데이트:
- v0.1.0 상태 선언
- 빠른 시작 가이드
- CLI 13 서브커맨드 목록
- 문서 링크

Tests (4 신규, 105 total pass):
- 마이그레이션 스캐너 (agents/scripts/workflows 감지)
- node_modules/.git 제외
- 빈 아카이브 처리
- scaffold 디렉토리 구조 검증

검증: tsc --noEmit ✓ | vitest 105/105 ✓ | build ✓
       rails doctor → 정상 출력 ✓
       rails --help → 13 subcommands ✓

마감 상태:
- F1~F6 모든 실패 모드 코어에서 해결
- 7 스프린트 완료 (000: 계획, 001~006: 코어, 007: 릴리즈)
- 105 테스트, 19 문서 (.plans/) + 3 운영 문서 (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:53:54 +09:00

5.9 KiB

Discord Setup

How to wire hanarang-rails to a Discord guild for the real DiscordTransport.

Overview

Rails splits transport and observation:

  • Transport (deterministic): Rails posts marker blocks with structured invoke data. Agent bots parse the markers directly (not through LLM).
  • Observation (natural language): Agent bots keep posting free-form messages for human readers. Rails ignores the free-form text.

Bot accounts

Two kinds of discord bots are involved:

  1. Rails bot — posts invoke markers, state transitions, escalations.
  2. Agent bots (one per role, optional) — each agent/sister has its own bot persona that responds with result markers and natural-language commentary.

If you don't need per-role personas, you can run a single bot for both rails and all agents.

Rails bot setup

  1. Go to https://discord.com/developers/applications
  2. Create a new application → bot user
  3. Enable privileged intents: Message Content Intent must be on.
  4. OAuth2 URL generator → scopes: bot, permissions: Send Messages, Read Message History, Create Public Threads, Manage Messages (for marker cleanup, optional).
  5. Invite the bot to your guild.
  6. Copy the token.

Set in .env:

RAILS_DISCORD_TOKEN=<token>
DISCORD_GUILD_ID=<guild-id>
DISCORD_PIPELINE_CHANNEL_ID=<channel-id>

Agent bot integration

Each agent host needs a minimal message handler that recognizes rails markers and routes them out of the LLM path:

import { DiscordPoster } from "hanarang-rails";

client.on("messageCreate", async (msg) => {
  const invokeMarker = "<!-- rails:invoke v1 -->";
  if (msg.content.includes(invokeMarker)) {
    // Structured mode — do NOT send to the LLM
    const req = extractJsonBlock(msg.content, "rails:invoke");
    const result = await runRailsTask(req);   // your agent's task runner
    const resultMarker =
      "<!-- rails:result v1 -->\n```json\n" +
      JSON.stringify(result) +
      "\n```\n<!-- /rails:result -->";
    await msg.channel.send(
      resultMarker +
      "\n\n(Agent natural-language commentary here, optional)"
    );
    return;
  }

  // Otherwise: existing free-form conversation path
  await runFreeFormLlm(msg);
});

Marker format

Invoke (rails → agent):

<!-- rails:invoke v1 -->
```json
{
  "pipelineId": "01HW0...",
  "contractId": "01HW1...",
  "stage": "implement",
  "role": "implement",
  "sprintId": "SPRINT-007",
  "task": {
    "title": "Add feature X",
    "description": "...",
    "workdir": "/path/to/workdir"
  },
  "timeoutMs": 30000,
  "structuredOutput": true
}

**Result** (agent → rails):
{
  "stage": "implement",
  "verdict": "IMPL_DONE",
  "payload": {
    "branch": "feature/sprint-007",
    "commits": ["abc1234"],
    "workdir": "...",
    "selfTestReport": {"typecheck": "pass"}
  },
  "errorReason": ""
}

구현 완료했어요! 테스트 전부 통과했습니다 ❤️


The natural-language tail after `/rails:result` is free-form — rails ignores it, but the human user sees it.

## DiscordPoster interface

To wire rails to a real discord.js client, implement `DiscordPoster` and pass it when constructing `DiscordTransport`:

```ts
import { Client, GatewayIntentBits, TextChannel } from "discord.js";
import { DiscordTransport, type DiscordPoster } from "hanarang-rails";

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});
await client.login(process.env.RAILS_DISCORD_TOKEN);

const poster: DiscordPoster = {
  async postMessage(channelId, content) {
    const channel = await client.channels.fetch(channelId);
    if (!channel?.isTextBased()) throw new Error("Not a text channel");
    const msg = await (channel as TextChannel).send(content);
    return msg.id;
  },
  async waitForResult({ channelId, pipelineId, stage, timeoutMs, signal }) {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
      signal?.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(new Error("aborted"));
      });
      const handler = (msg: any) => {
        if (msg.channel.id !== channelId) return;
        const body = msg.content as string;
        if (!body.includes("<!-- rails:result v1 -->")) return;
        if (!body.includes(pipelineId)) return;
        if (!body.includes(`"stage":"${stage}"`)) return;
        clearTimeout(timer);
        client.off("messageCreate", handler);
        resolve(body);
      };
      client.on("messageCreate", handler);
    });
  },
  async close() {
    await client.destroy();
  },
};

const transport = new DiscordTransport({
  token: process.env.RAILS_DISCORD_TOKEN!,
  guildId: process.env.DISCORD_GUILD_ID!,
  channelId: process.env.DISCORD_PIPELINE_CHANNEL_ID!,
  poster,
});

Per-pipeline threads

For cleanness, create a forum thread per pipeline:

// On state transition, create a thread under the pipeline channel
const thread = await (channel as TextChannel).threads.create({
  name: `[SPRINT-007] ${projectName}`,
  autoArchiveDuration: 1440,
});

Pass thread.id as channelId when invoking agents. Rails stores the pipeline → thread mapping in SQLite (pipelines.contextJson).

Testing without a live bot

For development, use rails run --mock — the MockTransport doesn't touch discord and returns deterministic success messages. All tests ship with a fake poster; no real tokens needed.

Security

  • Never commit tokens. .env is gitignored. Use secrets manager for production.
  • Validate HMAC on any inbound webhooks (Gitea). See GITEA_WEBHOOK_SECRET.
  • Rate limit guard: rails retries on 429 with exponential backoff (Sprint 005).
  • operations.md — day-to-day ops
  • migration-guide.md — porting from legacy bridges
  • .plans/design/transports.md — transport abstraction design