Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ mcp-server/
| `mark_reflected` | 관계있다로 답한 변경을 내 작업에 반영했다고 표시하거나 풀기. 사람이 직접 말했을 때만 | |

알리기 도구는 `X-Sil-User`의 이름으로만 움직여요. 다른 사람의 목록을 보거나 대신 답할 수 없고, 이름이 없으면 거절해요.
`respond`는 DB까지 가기 전에 `@sil/notify`의 `checkAnswer`로 먼저 검사해서 빠진 칸을 한국어로 알려 줘요. 이미 닫힌 변경이면 `closedStatus`(답함·대체됨·자동 닫힘)를 돌려줘요. "관계있다"로 저장되면 `rereadStandardId`를 돌려줘서 AI가 그 결정 사항을 `get_standard`로 다시 읽어요.
`respond`는 DB까지 가기 전에 `@sil/notify`의 `checkAnswer`로 먼저 검사해서 빠진 칸을 한국어로 알려 줘요. 이미 닫힌 변경이면 `closedStatus`(답함·대체됨·자동 닫힘)를 돌려줘요. "잘 모르겠다"는 `unsure: true`로 불러서 DB에 쓰지 않고 대기로 남겨요 (notify.answer.unsure-later). "관계있다"로 저장되면 `rereadStandardId`를 돌려줘서 AI가 그 결정 사항을 `get_standard`로 다시 읽어요.
`mark_reflected`는 사람이 "작업에 반영했어요"라고 말했을 때만 불러요 (SIL-93). 관계있다로 답한 변경에만 표시할 수 있고(notify.check.reflected v2), 이 규칙은 `@sil/notify`의 `checkReflect`가 DB까지 가기 전에 봐요. 표시를 풀어도 이슈는 다시 열리지 않아요. DB가 거절하면 거절 문장을 그대로 돌려줘요.

팀 위키 `wiki.mcp.five-tools`에 따라 위키 도구는 다섯 개예요. 폐기는 사람이 `pnpm sil deprecate`로 해요.
Expand Down
118 changes: 118 additions & 0 deletions apps/mcp-server/src/hooks/notify-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,121 @@ describe('이슈 반영 (SIL-93, 부건님 10/15 시안)', () => {
expect(refused.json).toEqual({});
});
});

describe('잘 모르겠다 (notify.answer.unsure-later)', () => {
/** 민수님에게 #5(c-1)와 #8(c-8) 두 건이 온 상태 */
async function twoPending() {
const data = await store.read();
await store.write({
...data,
issues: [
...data.issues,
{
key: '#8',
title: '설치 안내 쓰기',
assignee: '민수',
status: 'in_progress',
standardIds: [STANDARD],
},
],
pendingChecks: [
...data.pendingChecks,
{
...data.pendingChecks.find((c) => c.id === 'c-1')!,
id: 'c-8',
issueKey: '#8',
createdAt: '2026-10-14T05:12:00Z',
},
],
});
}

const unsure = (pendingCheckId: string) =>
hook('after-respond', {
session_id: 's1',
tool_name: 'mcp__haribo__respond',
tool_input: { pendingCheckId, unsure: true },
tool_response: [
{ type: 'text', text: JSON.stringify({ ok: true, unsure: true }) },
],
});

it('안내에 잘 모르겠다를 고르면 unsure: true로 부르라고 적어요', async () => {
const first = await hook('prompt-submit', {
session_id: 's1',
prompt: '작업',
});
expect(first.context).toContain('unsure: true');
});

it('고르면 한 줄을 띄우고 다음 변경 상세로 넘어가요', async () => {
await twoPending();
await hook('prompt-submit', { session_id: 's1', prompt: '작업' });
const after = await unsure('c-1');
expect(after.screen).toContain(
'나중에 답할 수 있게 남겨 두었어요. "결정 사항 확인"으로 다시 볼 수 있어요.',
);
expect(after.screen).toContain('확인할 변경이 1건 남았어요');
expect(after.screen).toContain('#8 설치 안내 쓰기');
});

it('남은 게 없으면 한 줄만 띄우고, 이번 세션에는 다시 묻지 않아요', async () => {
await hook('prompt-submit', { session_id: 's1', prompt: '작업' });
const after = await unsure('c-1');
expect(after.screen).toBe(
'\n 나중에 답할 수 있게 남겨 두었어요. "결정 사항 확인"으로 다시 볼 수 있어요.',
);
// 다음 입력, 작업을 마친 뒤 모두 조용해요
const next = await hook('prompt-submit', {
session_id: 's1',
prompt: '작업 2',
});
expect(next.json).toEqual({});
expect((await hook('after-work', { session_id: 's1' })).json).toEqual({});
});

it('"결정 사항 확인"을 입력하면 잘 모르겠다로 남긴 변경도 다시 물어요', async () => {
await hook('prompt-submit', { session_id: 's1', prompt: '작업' });
await unsure('c-1');
const recall = await hook('prompt-submit', {
session_id: 's1',
prompt: '결정 사항 확인',
});
expect(recall.screen).toContain('확인할 변경이 1건 있어요');
});

it('새 세션에서는 다시 물어요', async () => {
await hook('prompt-submit', { session_id: 's1', prompt: '작업' });
await unsure('c-1');
const other = await hook('prompt-submit', {
session_id: 's2',
prompt: '작업',
});
expect(other.screen).toContain('작업 전에 확인할 변경이 1건 있어요');
});

it('앞은 잘 모르겠다, 끝은 답하면 "모두" 없이 답한 건수만 말해요', async () => {
await twoPending();
await hook('prompt-submit', { session_id: 's1', prompt: '작업' });
await unsure('c-1');
await respond(
store,
{
pendingCheckId: 'c-8',
answeredBy: '민수',
affected: true,
known: true,
channel: 'terminal',
},
{ now: '2026-10-14T05:20:00Z' },
);
const done = await hook('after-respond', {
session_id: 's1',
tool_input: { pendingCheckId: 'c-8', affected: true, known: true },
tool_response: [{ type: 'text', text: JSON.stringify({ ok: true }) }],
});
expect(done.screen).toContain(
'확인할 변경 1건을 답했어요. 이제 작업을 시작할게요.',
);
});
});
89 changes: 61 additions & 28 deletions apps/mcp-server/src/hooks/notify-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
reflectScreen,
sessionNotice,
skippedReminder,
unsureLine,
type NotifyData,
type PendingItem,
} from '@sil/notify';
Expand Down Expand Up @@ -74,7 +75,11 @@ export interface HookInput {
session_id?: string;
prompt?: string;
tool_name?: string;
tool_input?: { pendingCheckId?: unknown; reflected?: unknown };
tool_input?: {
pendingCheckId?: unknown;
reflected?: unknown;
unsure?: unknown;
};
tool_response?: unknown;
}

Expand All @@ -86,6 +91,11 @@ interface FlowState {
totalAtStart: number;
/** "결정 사항 확인"으로 다시 부른 것인지. 끝나도 작업을 시작하지 않아요 */
recall: boolean;
/**
* 이번 세션에서 "잘 모르겠다"로 남긴 확인할 변경 (notify.answer.unsure-later).
* 이번 세션에는 다시 묻지 않고, 작업을 마친 뒤 한 줄의 건수에서도 빼요. "결정 사항 확인"을 입력하면 비워서 다시 물어요
*/
deferred: Set<string>;
updatedAt: number;
}

Expand All @@ -100,14 +110,14 @@ const toolName = (name: string) => `mcp__${SERVER_INFO.name}__${name}`;
*/
function buildGuide(recall: boolean): string {
const after = recall
? '모든 변경을 기록했거나 사용자가 질문을 닫았으면 글을 쓰지 않고 끝낸다. 사용자의 입력은 답하기를 다시 부른 것이라 따로 처리할 요청이 없다.'
: '모든 변경을 기록했거나 사용자가 질문을 닫았으면 사용자의 원래 요청을 처리한다. 이때도 확인한 변경에 대해서는 따로 말하지 않는다.';
? '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면 글을 쓰지 않고 끝낸다. 사용자의 입력은 답하기를 다시 부른 것이라 따로 처리할 요청이 없다.'
: '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면 사용자의 원래 요청을 처리한다. 이때도 확인한 변경에 대해서는 따로 말하지 않는다.';
return `[확인할 변경 규칙] 사용자의 요청을 처리하기 전에 아래 순서를 먼저 끝낸다.
변경 상세와 기록 결과는 hooks가 사용자 화면에 이미 직접 보여 준다. 그러니 상세 내용, 요약, 표, 인사말, 진행 설명을 직접 쓰지 않는다. 서버 안내문 8번의 "표로 보여 주기"도 하지 않는다. 글은 한 글자도 출력하지 말고 도구만 부른다.
1. ${toolName('list_pending')} 을 부른다.
2. items 의 변경마다 순서대로, 그 변경의 questions 를 쓴다:
a. AskUserQuestion 으로 questions.relation 을 묻는다. question, header, 선택지 label 과 description 을 주어진 그대로 쓰고, multiSelect 는 false, 추천 표시는 붙이지 않는다.
b. 관계있다를 고르면 questions.known 을, 관계없다를 고르면 questions.reason 을 같은 규칙으로 묻는다.
b. 관계있다를 고르면 questions.known 을, 관계없다를 고르면 questions.reason 을 같은 규칙으로 묻는다. 잘 모르겠다를 고르면 더 묻지 않고 ${toolName('respond')} 를 pendingCheckId 와 unsure: true 로만 부른다(affected 없이). 기록되지 않고 나중에 답하도록 남으며, 아무 말 없이 다음 변경으로 간다.
c. relation 이나 known 질문에서 사용자가 보기를 고르지 않고 직접 글을 쓰거나 대화를 원하면, 그 글을 답으로 기록하지 않는다. 글이 질문이면 위에 보여 준 변경 상세에 있는 내용만으로 한두 문장으로 답하고, 같은 질문을 다시 묻는다.
d. reason 질문에서 고른 보기의 label 이나 직접 쓴 글은 그대로 unaffectedReason 으로 기록한다.
e. ${toolName('respond')} 로 답을 기록한다. pendingCheckId 는 그 변경의 것, affected 는 관계있다면 true, known 은 알았다면 true·몰랐다면 false 다.
Expand Down Expand Up @@ -293,6 +303,13 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) {
}

const previous = flows.get(flowKey);
// 이번 세션에 잘 모르겠다로 남긴 변경은 다시 묻지 않아요. "결정 사항 확인"이면 함께 다시 물어요 (notify.answer.unsure-later)
const deferred = recall
? new Set<string>()
: new Set(previous?.deferred ?? []);
const flowItems = items.filter((i) => !deferred.has(i.pendingCheckId));
if (flowItems.length === 0) return withReminder(data, user, flowKey, null);

// 이번 세션에서 이미 물었는데 대기가 남았으면 닫고 넘어간 거예요. 다 답한 뒤 새 변경이 와도 같아요 (SIL-80 B5).
// 한 줄은 작업을 마친 뒤(afterWork) 띄워요. 입력할 때 띄우면 작업 결과에 밀려 올라가서 못 봤어요 (notify.terminal.new-after-answered v2)
if (previous?.asked && !recall) {
Expand All @@ -304,12 +321,13 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) {

const flow: FlowState = {
asked: true,
totalAtStart: items.length,
totalAtStart: flowItems.length,
recall,
deferred,
updatedAt: now().getTime(),
};
flows.set(flowKey, flow);
const first = items[0] as PendingItem;
const first = flowItems[0] as PendingItem;
return promptOutput(detail(first, 1, flow), buildGuide(recall));
}

Expand All @@ -334,9 +352,29 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) {

const result = readRespondResult(input.tool_response);
const check = data.pendingChecks.find((c) => c.id === pendingCheckId);
const flowKey = key(user, input);
const flow =
flows.get(flowKey) ??
({
asked: true,
totalAtStart: items.length + 1,
recall: false,
deferred: new Set<string>(),
updatedAt: now().getTime(),
} satisfies FlowState);
flow.updatedAt = now().getTime();
flows.set(flowKey, flow);
const lines: string[] = [''];

if (check?.status === 'answered') {
if (
input.tool_input?.unsure === true &&
result?.ok === true &&
check?.status === 'pending'
) {
// 잘 모르겠다: 기록하지 않고 이번 세션 흐름에서만 빼요 (notify.answer.unsure-later)
flow.deferred.add(pendingCheckId);
lines.push(unsureLine());
} else if (check?.status === 'answered') {
const answered = buildPendingList(
data,
{ recipient: user, includeClosed: true },
Expand All @@ -355,24 +393,15 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) {
return {};
}

const flowKey = key(user, input);
const flow =
flows.get(flowKey) ??
({
asked: true,
totalAtStart: items.length + 1,
recall: false,
updatedAt: now().getTime(),
} satisfies FlowState);
flow.updatedAt = now().getTime();
flows.set(flowKey, flow);

const next = items[0];
if (!next) {
lines.push('', allAnsweredLine(flow.totalAtStart, flow.recall));
} else {
const index = Math.max(1, flow.totalAtStart - items.length + 1);
const remaining = items.filter((i) => !flow.deferred.has(i.pendingCheckId));
const next = remaining[0];
if (next) {
const index = Math.max(1, flow.totalAtStart - remaining.length + 1);
lines.push(detail(next, index, flow));
} else if (input.tool_input?.unsure !== true) {
// 잘 모르겠다로 끝났으면 한 줄만 띄우고 마무리 줄은 빼요 (부건님 시안 after_unsure)
const answered = flow.totalAtStart - flow.deferred.size;
lines.push('', allAnsweredLine(flow.totalAtStart, flow.recall, answered));
}
return { systemMessage: lines.join('\n') };
}
Expand Down Expand Up @@ -412,11 +441,15 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) {
user: string,
input: HookInput,
): Promise<HookOutput> {
if (!flows.get(key(user, input))?.asked) return {};
const flow = flows.get(key(user, input));
if (!flow?.asked) return {};
try {
const { items } = await pending(user);
return items.length > 0
? { systemMessage: skippedReminder(items.length) }
// 잘 모르겠다로 남긴 변경은 건수에서 빼고, 그것만 남았으면 띄우지 않아요
const left = (await pending(user)).items.filter(
(i) => !flow.deferred.has(i.pendingCheckId),
);
return left.length > 0
? { systemMessage: skippedReminder(left.length) }
: {};
} catch (error) {
logFailure('after-work', user, error);
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp-server/src/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const USAGE = [
'6. save_standard와 revert는 CHANGELOG 한 줄(changelogLine)을 돌려줘요. 작업 중인 저장소에 team-wiki/CHANGELOG.md가 있으면 그 줄을 맨 아래에 붙여요.',
// 일반 세션에서도 확인할 변경을 묻게 해요. hooks 없이 서버 안내문과 list_pending 결과(questions)만으로 돼요
'7. 사람이 작업을 시작하면 그 일보다 먼저 list_pending을 불러요 (notify.terminal.session-start). 한 세션에서 한 번 확인했으면 사람이 다시 묻기 전에는 또 부르지 않아요.',
'8. 대기 중인 확인할 변경이 있으면 하나씩 차례로 다뤄요 (notify.terminal.one-change). 결정 사항 제목, 이전 값, 지금 값, 이유, 바꾼 사람과 시각, 내 이슈를 표로 보여 주고(notify.terminal.show-fields), 바로 이어서 그 항목의 questions를 AskUserQuestion으로 글자 그대로 물어요. 고른 답으로 respond를 부르고 다음 변경으로 넘어가요. 다 끝나면 원래 하려던 일을 이어서 해요.',
'8. 대기 중인 확인할 변경이 있으면 하나씩 차례로 다뤄요 (notify.terminal.one-change). 결정 사항 제목, 이전 값, 지금 값, 이유, 바꾼 사람과 시각, 내 이슈를 표로 보여 주고(notify.terminal.show-fields), 바로 이어서 그 항목의 questions를 AskUserQuestion으로 글자 그대로 물어요. 고른 답으로 respond를 부르고 다음 변경으로 넘어가요. 잘 모르겠다를 고르면 더 묻지 않고 respond를 unsure: true로 불러요(기록되지 않고 대기로 남아요). 다 끝나면 원래 하려던 일을 이어서 해요.',
// 이슈 반영 표시는 사람이 말했을 때만 해요. AI가 코드를 보고 짐작하지 않아요 (SIL-93, notify.check.reflected)
'9. 이슈 반영: 사람이 관계있다로 답한 변경을 "이슈에 반영했어요"라고 말하면 mark_reflected로 표시해요. "이슈 반영"만 입력하면 hooks가 목록과 질문을 알려 주니 그대로 따라요. 표시를 풀어 달라고 하면 reflected를 false로 불러요. 코드를 보고 반영했다고 짐작해서 표시하지 않아요.',
].join('\n');
Expand Down
36 changes: 35 additions & 1 deletion apps/mcp-server/src/notify-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ describe('list_pending', () => {
question: '이 변경이 내 이슈와 관계가 있나요?',
header: '#5',
});
expect(labels('relation')).toEqual(['관계있다', '관계없다']);
expect(labels('relation')).toEqual(['관계있다', '관계없다', '잘 모르겠다']);
expect(labels('known')).toEqual(['알았다', '몰랐다']);
expect(labels('reason')).toHaveLength(3);
});
Expand Down Expand Up @@ -457,6 +457,40 @@ describe('respond', () => {
expect(fake.rpcCalls).toEqual([]);
});

it('잘 모르겠다는 DB에 아무것도 쓰지 않고 대기로 남겨요 (notify.answer.unsure-later)', async () => {
const result = await call(await connectAs('민수'), 'respond', {
pendingCheckId: 'c-5',
unsure: true,
});
expect(result.json()).toEqual({
ok: true,
unsure: true,
pendingCheckId: 'c-5',
issueKey: '#5',
});
expect(fake.rpcCalls).toEqual([]);
});

it('잘 모르겠다도 다른 사람의 변경에는 쓸 수 없어요', async () => {
const result = await call(await connectAs('부건'), 'respond', {
pendingCheckId: 'c-5',
unsure: true,
});
expect(result.json()).toMatchObject({ ok: false, code: 'not_recipient' });
});

it('관계도 잘 모르겠다도 없으면 빠진 칸을 알려 줘요', async () => {
const result = await call(await connectAs('민수'), 'respond', {
pendingCheckId: 'c-5',
});
expect(result.json()).toMatchObject({
ok: false,
code: 'missing_fields',
missingFields: ['affected'],
});
expect(fake.rpcCalls).toEqual([]);
});

it('다른 사람에게 온 변경에는 답할 수 없어요', async () => {
const result = await call(await connectAs('부건'), 'respond', {
pendingCheckId: 'c-5',
Expand Down
Loading
Loading