feat(notify): junior 가 LLM 응답에 직접 discord 한 줄 멘트 emit (옵션 C)

자기야 결정: 옵션 C — junior 가 자기 work 끝에 ```discord-line``` 블록을
출력하고, sister-agent 가 그걸 추출해서 stage-end Discord notify 메시지로
사용. 추가 LLM 호출 0, 매번 다른 메시지, 자매 본인이 자기가 한 일을 직접
보고하는 느낌.

## 변경

### prompts.ts
- buildPrompt 의 footer 에 role==='junior' 분기 추가
- 새 helper discordLineFooter(ctx): 자매별 페르소나 + stage 별 예시 포함
- 출력 형식 강제: ```discord-line\n<한 줄>\n```
- 50 자 이내, 이모지 1-2 개, 실패시 ✗/⚠️/🛑 명시 지시

### discord-notify.ts
- StageMessageContext.customLine 필드 추가
- extractDiscordLines(text): 모든 ```discord-line``` 블록 추출 +
  본문에서 strip → { lines, cleaned }
- renderStageEnd 가 customLine 우선, 없으면 hardcoded 풀로 fallback

### spawn.ts
- aggregated 만들기 전에 extractDiscordLines() 호출
- cleaned 텍스트만 buildSuccessResult / priorStages 에 전달 (chat noise
  가 다음 stage 의 LLM context 로 새지 않게)
- 첫 번째 추출된 line 을 customLine 으로 renderStageEnd 에 전달

## 효과

이전: 다랑이가 항상 "🔍 리뷰 통과! 이랑이 받아" 같은 4 variant pool 에서
픽 → 식상 + 작업 내용 무관

이후: 다랑이가 직접 LLM 응답 끝에 emit
  - "🔍 체크리스트 다 ✓. addTodo/toggleTodo/deleteTodo 모두 동작. 이랑이 받아."
  - "⚠️ addTodo 가 빈 입력 처리 못 함. 나랑아 다시 봐줘."
LLM 이 자기가 본 코드의 실제 결함을 한 줄로 요약. 같은 stage 라도 매번
다른 메시지. fallback 은 그대로 작동.
This commit is contained in:
2026-04-11 16:24:56 +09:00
parent ece52f9dd5
commit c58bc311a6
3 changed files with 117 additions and 3 deletions

View File

@@ -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<line>\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<newline><single-line content><newline>```
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<string, string[]> = {
@@ -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}`;

View File

@@ -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<string, string> = {
harang: "차분하고 단정한 plan 단계 부장",
narang: "활달하고 실용적인 implement 단계 부장",
darang: "꼼꼼하고 엄격한 review 단계 부장",
erang: "차분하고 믿음직한 deploy 단계 부장",
};
const exampleByStage: Record<string, string> = {
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

View File

@@ -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) => {