diff --git a/sister-agent/src/discord-notify.ts b/sister-agent/src/discord-notify.ts index c7b414a..f823fdd 100644 --- a/sister-agent/src/discord-notify.ts +++ b/sister-agent/src/discord-notify.ts @@ -102,6 +102,43 @@ export interface StageMessageContext { verdict?: string; childCount?: number; filesProduced?: number; + /** + * Optional custom line provided by the LLM itself (extracted from a + * `discord-line` code block in the junior output). When set, this line + * is used verbatim instead of picking from the hardcoded pool. Falls + * back to the pool if empty / undefined. + */ + customLine?: string; +} + +/** + * Pull every ```discord-line\n\n``` block out of an LLM text blob. + * Returns the extracted lines AND the original text with all such blocks + * removed (so it can be safely passed to downstream stages without chat + * noise polluting their priorStages context). + * + * The block is intentionally a fenced code block so it doesn't conflict + * with regular markdown formatting and is easy for the LLM to emit + * verbatim. + */ +export function extractDiscordLines(text: string): { + lines: string[]; + cleaned: string; +} { + if (!text) return { lines: [], cleaned: text }; + // Match: ```discord-line``` + const pattern = /```discord-line\s*\n([^\n`]*)\n```/g; + const lines: string[] = []; + let m: RegExpExecArray | null; + while ((m = pattern.exec(text)) !== null) { + const line = (m[1] ?? "").trim(); + if (line) lines.push(line); + } + const cleaned = text + .replace(/```discord-line\s*\n[^\n`]*\n```/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return { lines, cleaned }; } const START_POOLS: Record = { @@ -244,6 +281,15 @@ export function renderStageEnd(ctx: StageMessageContext): string { ctx.filesProduced && ctx.filesProduced > 0 ? ` (산출물 ${ctx.filesProduced}개)` : ""; + + // LLM-supplied custom line wins. The junior who actually did the work + // already knows what to say — use it verbatim. (We still substitute + // {tail} in case the LLM left the placeholder in.) + if (ctx.customLine && ctx.customLine.trim().length > 0) { + return ctx.customLine.trim().replace("{tail}", tail); + } + + // Otherwise fall back to the hardcoded persona pool. const key = endPoolKey(ctx.agentName, ctx.stage, ctx.verdict); const pool = END_POOLS[key]; if (!pool) return `✅ ${ctx.stage} 완료${tail}`; diff --git a/sister-agent/src/prompts.ts b/sister-agent/src/prompts.ts index 0cf6df1..083c6fa 100644 --- a/sister-agent/src/prompts.ts +++ b/sister-agent/src/prompts.ts @@ -89,9 +89,63 @@ export function buildPrompt(ctx: PromptContext): string { lines.push(roleOutputHint(ctx.role, ctx.stage)); lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`); + // 모든 작업 결과 끝에 디스코드용 한 줄 멘트를 LLM 이 직접 emit 하게 한다. + // sister-agent 가 이 블록을 추출해 stage-end Discord notify 메시지로 사용 + // 한다 (없으면 hardcoded 풀로 fallback). junior 가 가장 작업 내용을 잘 + // 알기 때문에 junior 에만 요청한다 — manager 는 작업 시작 전에 결정만 함. + if (ctx.role === "junior") { + lines.push(""); + lines.push(discordLineFooter(ctx)); + } + return lines.join("\n"); } +/** + * Footer instructing the junior LLM to append a `discord-line` block at + * the end of its response. The block is parsed by the sister-agent and + * used as the stage-end Discord notification message. + * + * Persona context (자매 정체성) is included so the LLM matches tone: + * harang — 차분/단정 + * narang — 활달/실용 + * darang — 꼼꼼/엄격 + * erang — 차분/믿음직 + */ +function discordLineFooter(ctx: PromptContext): string { + const persona: Record = { + harang: "차분하고 단정한 plan 단계 부장", + narang: "활달하고 실용적인 implement 단계 부장", + darang: "꼼꼼하고 엄격한 review 단계 부장", + erang: "차분하고 믿음직한 deploy 단계 부장", + }; + const exampleByStage: Record = { + plan: '"📋 MVP 범위 잡았어. 나랑이 받아."', + implement: '"🔨 todo HTML 5개 함수 박았어. 다랑아 봐줘."', + review: + '"🔍 체크리스트 다 ✓. 이랑이 받아." (APPROVE) 또는 ' + + '"⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘." (REQUEST_CHANGES)', + deploy: '"🚀 todo-mvp.html 검증 완료. 안전해."', + }; + return [ + `# 디스코드 알림 한 줄`, + `자기야가 디스코드 채널에서 보게 될 너의 한 줄 보고를 마지막에 추가해.`, + `너는 ${persona[ctx.agentName] ?? ctx.agentName} 의 페르소나를 살려.`, + `방금 너가 한 작업의 핵심을 한 줄로 요약 (50 자 이내, 이모지 1-2개).`, + `결과가 실패/REQUEST_CHANGES/ABORT 면 그 사실을 명확히 (✗/⚠️/🛑 중 하나) 표시.`, + ``, + `**정확히 다음 형식으로** 응답 맨 끝에 추가:`, + "```discord-line", + "<여기에 한 줄>", + "```", + ``, + `예시 (${STAGE_KOREAN[ctx.stage]}):`, + exampleByStage[ctx.stage] ?? '"✅ 작업 완료"', + ``, + `이 블록은 따로 파싱되니까 위 형식 정확히 지켜. 본문 어디 다른 곳에는 같은 형식 쓰지 마.`, + ].join("\n"); +} + function roleOutputHint(role: Role, stage: PromptContext["stage"]): string { // Stage-specific instructions take precedence. The original "decompose // into team" wording only makes sense for plan / implement — for review diff --git a/sister-agent/src/spawn.ts b/sister-agent/src/spawn.ts index ce449b3..b5ba324 100644 --- a/sister-agent/src/spawn.ts +++ b/sister-agent/src/spawn.ts @@ -23,6 +23,7 @@ import { notifyDiscord, renderStageStart, renderStageEnd, + extractDiscordLines, } from "./discord-notify.js"; // Real LLM call is the default. Set USE_REAL_LLM=false (or the legacy @@ -213,10 +214,17 @@ export async function executeInvocation( // — to make a meaningful judgement, so the cap is generous. Cap is // sized for full HTML/JS/CSS files; LLM context windows are 200k+ so // 64KB stays well inside budget even after 4 stages of accumulation. - const aggregated = [managerWork.text, ...childTexts] + const aggregatedRaw = [managerWork.text, ...childTexts] .filter(Boolean) - .join("\n\n---\n\n") - .slice(0, 64_000); + .join("\n\n---\n\n"); + + // Pull every ```discord-line``` block out before slicing/persisting. + // The first extracted line becomes the stage-end Discord message; + // the cleaned text (with the blocks stripped) is what flows to the + // next stage as priorStages so chat noise doesn't bleed through. + const { lines: discordLines, cleaned: aggregatedClean } = + extractDiscordLines(aggregatedRaw); + const aggregated = aggregatedClean.slice(0, 64_000); const result = buildSuccessResult( req.stage, @@ -230,8 +238,13 @@ export async function executeInvocation( // so the message reflects the ACTUAL verdict ("리뷰 통과" vs "결함 발견" // vs "배포 실패"). Previously this was emitted before the verdict was // known, so darang would always say "통과" even when REQUEST_CHANGES. + // + // If a junior LLM emitted a `discord-line` block, use that verbatim + // (it's the LLM speaking in character about its own work). Otherwise + // fall back to the hardcoded persona pool. if (req.notifyChannelId) { const verdict = "verdict" in result ? result.verdict : ""; + const customLine = discordLines[0] ?? ""; notifyDiscord({ channelId: req.notifyChannelId, message: renderStageEnd({ @@ -241,6 +254,7 @@ export async function executeInvocation( verdict, childCount: childTexts.length, filesProduced: ctx.producedFiles.length, + ...(customLine && { customLine }), }), }) .then((r) => {