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>
199 lines
5.9 KiB
Markdown
199 lines
5.9 KiB
Markdown
# 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:
|
|
|
|
```ts
|
|
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
|
|
}
|
|
```
|
|
<!-- /rails:invoke -->
|
|
```
|
|
|
|
**Result** (agent → rails):
|
|
```
|
|
<!-- rails:result v1 -->
|
|
```json
|
|
{
|
|
"stage": "implement",
|
|
"verdict": "IMPL_DONE",
|
|
"payload": {
|
|
"branch": "feature/sprint-007",
|
|
"commits": ["abc1234"],
|
|
"workdir": "...",
|
|
"selfTestReport": {"typecheck": "pass"}
|
|
},
|
|
"errorReason": ""
|
|
}
|
|
```
|
|
<!-- /rails:result -->
|
|
|
|
구현 완료했어요! 테스트 전부 통과했습니다 ❤️
|
|
```
|
|
|
|
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:
|
|
|
|
```ts
|
|
// 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).
|
|
|
|
## Related
|
|
|
|
- `operations.md` — day-to-day ops
|
|
- `migration-guide.md` — porting from legacy bridges
|
|
- `.plans/design/transports.md` — transport abstraction design
|