diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index e30bb85..859abf4 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -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`로 해요. diff --git a/apps/mcp-server/src/hooks/notify-hooks.test.ts b/apps/mcp-server/src/hooks/notify-hooks.test.ts index 486add1..bd01bd5 100644 --- a/apps/mcp-server/src/hooks/notify-hooks.test.ts +++ b/apps/mcp-server/src/hooks/notify-hooks.test.ts @@ -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건을 답했어요. 이제 작업을 시작할게요.', + ); + }); +}); diff --git a/apps/mcp-server/src/hooks/notify-hooks.ts b/apps/mcp-server/src/hooks/notify-hooks.ts index 50783f7..55e0bd4 100644 --- a/apps/mcp-server/src/hooks/notify-hooks.ts +++ b/apps/mcp-server/src/hooks/notify-hooks.ts @@ -38,6 +38,7 @@ import { reflectScreen, sessionNotice, skippedReminder, + unsureLine, type NotifyData, type PendingItem, } from '@sil/notify'; @@ -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; } @@ -86,6 +91,11 @@ interface FlowState { totalAtStart: number; /** "결정 사항 확인"으로 다시 부른 것인지. 끝나도 작업을 시작하지 않아요 */ recall: boolean; + /** + * 이번 세션에서 "잘 모르겠다"로 남긴 확인할 변경 (notify.answer.unsure-later). + * 이번 세션에는 다시 묻지 않고, 작업을 마친 뒤 한 줄의 건수에서도 빼요. "결정 사항 확인"을 입력하면 비워서 다시 물어요 + */ + deferred: Set; updatedAt: number; } @@ -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 다. @@ -293,6 +303,13 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) { } const previous = flows.get(flowKey); + // 이번 세션에 잘 모르겠다로 남긴 변경은 다시 묻지 않아요. "결정 사항 확인"이면 함께 다시 물어요 (notify.answer.unsure-later) + const deferred = recall + ? new Set() + : 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) { @@ -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)); } @@ -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(), + 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 }, @@ -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') }; } @@ -412,11 +441,15 @@ export function createNotifyHooks(options: NotifyHooksOptions = {}) { user: string, input: HookInput, ): Promise { - 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); diff --git a/apps/mcp-server/src/instructions.ts b/apps/mcp-server/src/instructions.ts index 2fb6150..ac35d2c 100644 --- a/apps/mcp-server/src/instructions.ts +++ b/apps/mcp-server/src/instructions.ts @@ -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'); diff --git a/apps/mcp-server/src/notify-tools.test.ts b/apps/mcp-server/src/notify-tools.test.ts index cce18a5..703d3fd 100644 --- a/apps/mcp-server/src/notify-tools.test.ts +++ b/apps/mcp-server/src/notify-tools.test.ts @@ -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); }); @@ -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', diff --git a/apps/mcp-server/src/tools/notify/respond.ts b/apps/mcp-server/src/tools/notify/respond.ts index 31c2bc0..c7f3f7e 100644 --- a/apps/mcp-server/src/tools/notify/respond.ts +++ b/apps/mcp-server/src/tools/notify/respond.ts @@ -1,6 +1,11 @@ import { isSilError } from '@sil/core'; import { listPendingChecks, saveAnswer } from '@sil/core/node'; -import { checkAnswer, closedFailure, type RespondInput } from '@sil/notify'; +import { + checkAnswer, + checkUnsure, + closedFailure, + type RespondInput, +} from '@sil/notify'; import { z } from 'zod'; import { defineTool } from '../../lib/define-tool.ts'; import { requireUser, toNotifyCheck } from './shared.ts'; @@ -18,6 +23,7 @@ export const respondTool = defineTool({ description: 'list_pending으로 본 확인할 변경 하나에 담당자의 답을 저장해요. ' + '먼저 list_pending 항목의 questions를 AskUserQuestion으로 글자 그대로 물어요: questions.relation을 묻고, 관계있다면 questions.known을, 관계없다면 questions.reason을 물어요. ' + + '잘 모르겠다를 고르면 더 묻지 않고 unsure: true로만 불러요(affected 없이). 답으로 기록하지 않고 대기로 남겨요. ' + '답을 지어내지 말고 사람이 고른 그대로 넣어요. 관계없다의 이유는 고른 보기의 label을, 직접 쓴 글이면 그 글을 unaffectedReason에 넣어요. ' + 'ok가 true이고 rereadStandardId가 있으면 그 결정 사항을 get_standard로 다시 읽고 이후 작업에 따라요. ' + 'ok가 false면 message를 그대로 보여 줘요. closedStatus가 auto_closed면 "원래대로 돌아와서 답하지 않아도 돼요"라고 알리고 다음 변경으로 넘어가요.', @@ -29,7 +35,16 @@ export const respondTool = defineTool({ .describe('list_pending 항목의 pendingCheckId'), affected: z .boolean() - .describe('관계있다면 true, 관계없다면 false. 사람이 고른 그대로'), + .optional() + .describe( + '관계있다면 true, 관계없다면 false. 사람이 고른 그대로. 잘 모르겠다면 넣지 않아요', + ), + unsure: z + .boolean() + .optional() + .describe( + '잘 모르겠다를 골랐으면 true. 답으로 기록하지 않고 대기로 남겨요 (notify.answer.unsure-later)', + ), known: z .boolean() .optional() @@ -51,10 +66,23 @@ export const respondTool = defineTool({ }), handler: async (input, context) => { const answeredBy = requireUser(context); + if (input.unsure) { + // 잘 모르겠다: DB에 아무것도 쓰지 않아요. 이번 세션에 다시 묻지 않는 것은 hooks가 기억해요 + const check = await findCheck(input.pendingCheckId); + const checked = checkUnsure(check, answeredBy); + if (!checked.ok) return checked; + return { + ok: true, + unsure: true, + pendingCheckId: input.pendingCheckId, + issueKey: check?.issueKey ?? null, + }; + } const answer: RespondInput = { pendingCheckId: input.pendingCheckId, answeredBy, - affected: input.affected, + // 비어 있으면 checkAnswer가 빠진 칸으로 알려 줘요 + affected: input.affected as boolean, known: input.known ?? null, unaffectedReason: input.unaffectedReason ?? null, // MCP 도구는 Claude Code 터미널에서만 불러요. 앱은 core의 saveAnswer를 바로 써요 diff --git a/packages/notify/README.md b/packages/notify/README.md index 48653be..7c10c36 100644 --- a/packages/notify/README.md +++ b/packages/notify/README.md @@ -71,6 +71,7 @@ const result = await respond(store, { - 거절하는 경우: 없는 변경, 받는 사람이 아님, 이미 답했거나 닫힘, 답한 곳이 터미널·앱이 아님, 빠진 칸, 이유가 100자 넘음. - 한 곳에서 답하면 그 변경은 대기가 아니라서 다른 곳 목록에서 빠져요 (notify.answer.one-place). - 이미 닫힌 변경이면 `closedStatus`로 지금 상태(답함·대체됨·자동 닫힘)를 알려 줘요. 자동 닫힘이면 화면이 "원래대로 돌아와서 답하지 않아도 돼요"를 띄워요 (SIL-77). +- "잘 모르겠다"(notify.answer.unsure-later)는 답으로 기록하지 않아요. `checkUnsure`로 이 사람에게 온 대기 중인 변경인지만 보고, MCP 도구 `respond`는 `unsure: true`면 DB에 아무것도 쓰지 않아요. 이번 세션에 다시 묻지 않는 것은 서버 hooks가 기억하고, "결정 사항 확인"·다음 세션·앱에서는 다시 나와요. - 저장 전 검사만 하는 `checkAnswer`도 있어요. MCP 도구 `respond`는 이 함수로 검사한 뒤 DB 함수 `save_answer`로 저장해요. ### `checkReflect` — 이슈 반영 표시 검사 (MCP 도구 `mark_reflected`) diff --git a/packages/notify/src/index.ts b/packages/notify/src/index.ts index 653a295..ac515bf 100644 --- a/packages/notify/src/index.ts +++ b/packages/notify/src/index.ts @@ -33,6 +33,7 @@ export type { export { MAX_UNAFFECTED_REASON_LENGTH, checkAnswer, + checkUnsure, closedFailure, respond, } from './respond.ts'; @@ -85,6 +86,7 @@ export { sessionNotice, skippedReminder, sourceText, + unsureLine, } from './terminal/screens.ts'; export type { DetailOptions, RecordedAnswer } from './terminal/screens.ts'; export { diff --git a/packages/notify/src/respond.ts b/packages/notify/src/respond.ts index dcbb46f..dc33f5d 100644 --- a/packages/notify/src/respond.ts +++ b/packages/notify/src/respond.ts @@ -195,6 +195,25 @@ export function checkAnswer( }; } +/** + * "잘 모르겠다"를 골랐을 때 검사해요 (notify.answer.unsure-later). + * 답으로 기록하지 않고 대기로 남기니, 그 변경이 이 사람에게 온 대기 중인 변경인지만 봐요. + */ +export function checkUnsure( + check: PendingCheck | undefined, + answeredBy: string, +): { ok: true } | RespondFailure { + if (!check) return fail('not_found', '확인할 변경을 찾지 못했어요.'); + if (check.recipient !== answeredBy) { + return fail( + 'not_recipient', + `${check.recipient}님에게 온 확인할 변경이라 ${answeredBy}님은 답할 수 없어요.`, + ); + } + if (check.status !== 'pending') return closedFailure(check.status); + return { ok: true }; +} + /** 이미 닫힌 변경에 답했을 때의 결과. 지금 상태를 함께 돌려줘요 */ export function closedFailure( status: Exclude, diff --git a/packages/notify/src/terminal/questions.ts b/packages/notify/src/terminal/questions.ts index b87625e..8dce7ad 100644 --- a/packages/notify/src/terminal/questions.ts +++ b/packages/notify/src/terminal/questions.ts @@ -23,8 +23,12 @@ export interface ChangeQuestions { reason: Question; } -/** 관계있다 / 관계없다 보기 (위키 copy.term.related) */ -export const RELATION_LABELS = { affected: '관계있다', unaffected: '관계없다' }; +/** 관계있다 / 관계없다 / 잘 모르겠다 보기 (위키 copy.term.related, notify.answer.two-questions v2) */ +export const RELATION_LABELS = { + affected: '관계있다', + unaffected: '관계없다', + unsure: '잘 모르겠다', +}; /** 알았다 / 몰랐다 보기 */ export const KNOWN_LABELS = { known: '알았다', unknown: '몰랐다' }; @@ -61,6 +65,11 @@ export function questionsFor(item: PendingItem): ChangeQuestions { label: RELATION_LABELS.unaffected, description: '내 이슈와는 상관없는 변경이에요', }, + // 바로 판단하기 어려울 때 Esc나 Chat about this 대신 고르는 보기. 답으로 기록하지 않아요 (notify.answer.unsure-later) + { + label: RELATION_LABELS.unsure, + description: '지금은 판단하기 어려워요. 나중에 답할게요', + }, ], }, // 같은 변경이 이슈 두 개에 걸리면 두 번째에서 "방금 봤으니 알았다"로 답하기 쉬워서 기준 시점을 "오늘 알림"으로 적어요 diff --git a/packages/notify/src/terminal/screens.test.ts b/packages/notify/src/terminal/screens.test.ts index 1b0eca0..65bd528 100644 --- a/packages/notify/src/terminal/screens.test.ts +++ b/packages/notify/src/terminal/screens.test.ts @@ -20,6 +20,7 @@ import { reflectScreen, sessionNotice, skippedReminder, + unsureLine, } from './screens.ts'; import { questionsFor, @@ -369,6 +370,18 @@ describe('답한 뒤 (T4·T5)', () => { ]); }); + it('잘 모르겠다로 남긴 변경이 있으면 "모두" 없이 답한 건수만 말한다 (notify.answer.unsure-later)', () => { + assert.equal( + stripAnsi(allAnsweredLine(4, false, 3)), + '확인할 변경 3건을 답했어요. 이제 작업을 시작할게요.', + ); + assert.equal( + stripAnsi(unsureLine()), + ' 나중에 답할 수 있게 남겨 두었어요. "결정 사항 확인"으로 다시 볼 수 있어요.', + ); + assertFits(unsureLine()); + }); + it('"결정 사항 확인"만 입력했을 때만 다시 부른 것으로 본다', () => { assert.equal(isRecall('결정 사항 확인'), true); assert.equal(isRecall(' 결정 사항 확인. '), true); @@ -382,7 +395,7 @@ describe('질문', () => { assert.equal(q.relation.header, '#5'); assert.deepEqual( q.relation.options.map((o) => o.label), - ['관계있다', '관계없다'], + ['관계있다', '관계없다', '잘 모르겠다'], ); assert.deepEqual( q.known.options.map((o) => o.label), diff --git a/packages/notify/src/terminal/screens.ts b/packages/notify/src/terminal/screens.ts index ee8f7a9..0676b1f 100644 --- a/packages/notify/src/terminal/screens.ts +++ b/packages/notify/src/terminal/screens.ts @@ -372,13 +372,33 @@ export function recordedLines( return lines; } -/** 모든 변경에 답했을 때 마무리 한 줄. 1건이면 "1건을 모두"가 어색해서 숫자를 빼요 */ -export function allAnsweredLine(totalAtStart: number, recall: boolean): string { - const count = totalAtStart > 1 ? ` ${totalAtStart}건` : ''; +/** + * 모든 변경에 답했을 때 마무리 한 줄. 1건이면 "1건을 모두"가 어색해서 숫자를 빼요. + * "잘 모르겠다"로 남긴 변경이 있으면 "모두"라고 하지 않고 답한 건수만 말해요 (notify.answer.unsure-later). + */ +export function allAnsweredLine( + totalAtStart: number, + recall: boolean, + answered: number = totalAtStart, +): string { const start = recall ? '' : ' 이제 작업을 시작할게요.'; + if (answered < totalAtStart) { + return bold(`확인할 변경 ${answered}건을 답했어요.${start}`); + } + const count = totalAtStart > 1 ? ` ${totalAtStart}건` : ''; return bold(`확인할 변경${count}을 모두 답했어요.${start}`); } +/** + * "잘 모르겠다"를 고른 직후 한 줄 (notify.answer.unsure-later, 10/13 부건). + * 이번 세션에는 다시 알리지 않으니 다시 부르는 방법을 한 번은 알려 줘요. + */ +export function unsureLine(): string { + return statusLine( + `나중에 답할 수 있게 남겨 두었어요. ${recallWord()}으로 다시 볼 수 있어요.`, + ); +} + /** * 질문을 닫고 넘어간 뒤 Claude가 작업을 마칠 때마다 입력 칸 바로 위에 띄우는 한 줄 (notify.terminal.new-after-answered v2). * 모양은 v2 상태 줄(노란 막대)이에요. 입력할 때 띄우면 작업 결과에 밀려 올라가서 못 봤어요 (v1 피드백 P1·P3). diff --git a/team-wiki/CHANGELOG.md b/team-wiki/CHANGELOG.md index bc758eb..6081517 100644 --- a/team-wiki/CHANGELOG.md +++ b/team-wiki/CHANGELOG.md @@ -173,3 +173,5 @@ 2026-10-15 23:06 | 호윤 | app.card.found-in-pr-code-only 1→2 | 카드의 "PR에서 찾음" 표시는 AI가 찾았고 출처가 코드인 결정 사항에만 붙인다 → 카드의 출처 칩은 AI가 찾았고 출처가 코드인 결정 사항에만 "PR에서 찾은 변경"으로 보여 주고 따로 칩을 더 붙이지 않는다 | 신지님 시안(10/15)에서 PR에서 찾음 칩을 출처 칩 하나로 합쳤다 | AI 대화 2026-10-15 23:08 | 호윤 | notify.pending.renotify-hint 1→2 | 같은 결정 사항이 다시 바뀌면 지난번에 관계있다로 답한 사람에게만 "지난번 변경(이전 값)을 반영하셨다면 다시 고쳐야 해요." 안내를 붙인다 → 같은 결정 사항이 다시 바뀌면 지난번에 관계있다로 답한 사람에게만 "이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 주세요" 안내를 붙인다 | 신지님 시안(10/15)에서 안내 문구를 이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 주세요로 바꿨다 | AI 대화 2026-10-15 23:39 | 호윤 | app.detail.change-letter new→1 | (없음) → 변경 상세 위 칩은 결정 사항과 내 이슈 한 쌍마다 하나씩 두고 받은 순서대로 변경 A, 변경 B처럼 부른다 | 칩 하나가 결정 사항 하나를 가리키는데 결정 사항 ID는 칩에 넣기에 너무 길고 결정 사항에 정해진 번호가 없다 | AI 대화 +2026-10-16 10:26 | 민수 | notify.answer.two-questions 1→2 | 첫 질문은 이 변경이 내 작업에 영향을 주는지(관계있다/관계없다)이고, 관계있다면 미리 알았는지(알았다/몰랐다)를 묻는다 → 첫 질문은 이 변경이 내 작업에 영향을 주는지(관계있다/관계없다/잘 모르겠다)이고, 관계있다면 미리 알았는지(알았다/몰랐다)를 묻는다 | 주 지표인 몰랐다 수를 세려면 두 질문이 모두 필요하고, 바로 판단하기 어려운 사람이 Esc나 Chat about this 대신 고를 보기가 필요하다 | AI 대화 +2026-10-16 10:26 | 민수 | notify.answer.unsure-later new→1 | (없음) → 담당자가 잘 모르겠다를 고르면 답으로 기록하지 않고 그 확인할 변경을 대기로 남긴 채 다음 변경으로 넘어간다 | 바로 판단하기 어려운 변경을 억지로 답하게 하면 관계·알았나 답이 틀려지고, 결정한 사람과 이야기한 뒤 답할 시간이 필요하다 | AI 대화 diff --git a/team-wiki/INDEX.md b/team-wiki/INDEX.md index 1169c03..17ba8bc 100644 --- a/team-wiki/INDEX.md +++ b/team-wiki/INDEX.md @@ -23,7 +23,7 @@ | goal | 문제와-목표.md | 어떤 문제를 누구를 위해 풀고, 무엇으로 성공을 재는지 | 13 | | wiki | 기능-기준모으기.md | 기준을 저장·조회·되돌리는 규칙과 MCP 도구 | 13 | | detect | 기능-바뀐기준찾기.md | 반영된 PR을 보고 바뀐 기준을 찾는 방법과 안전 규칙 | 16 | -| notify | 기능-알리기.md | 이슈 연결, 영향받는 담당자 찾기, 터미널 알림, 응답, 예외 처리 | 30 | +| notify | 기능-알리기.md | 이슈 연결, 영향받는 담당자 찾기, 터미널 알림, 응답, 예외 처리 | 31 | | app | 앱-화면.md | 데스크톱 앱 화면, 앱 알림, 설치 | 12 | | data | 데이터-구조.md | ID 형식, 기록 형식, 상태 값 | 9 | | copy | 문구.md | 알림과 AI 설명의 문장 규칙, 함께 쓰는 말 | 8 | diff --git "a/team-wiki/\352\270\260\353\212\245-\354\225\214\353\246\254\352\270\260.md" "b/team-wiki/\352\270\260\353\212\245-\354\225\214\353\246\254\352\270\260.md" index 55b54f4..c1c3d68 100644 --- "a/team-wiki/\352\270\260\353\212\245-\354\225\214\353\246\254\352\270\260.md" +++ "b/team-wiki/\352\270\260\353\212\245-\354\225\214\353\246\254\352\270\260.md" @@ -136,11 +136,21 @@ - 버전: 2 ### notify.answer.two-questions -- 제목: 담당자는 관계있다/없다를 고르고, 관계있으면 알았다/몰랐다를 고른다 -- 값: 첫 질문은 이 변경이 내 작업에 영향을 주는지(관계있다/관계없다)이고, 관계있다면 미리 알았는지(알았다/몰랐다)를 묻는다 -- 이유: 주 지표인 몰랐다 수를 세려면 두 질문이 모두 필요하다 +- 제목: 담당자는 관계 보기 3개 중 하나를, 관계있으면 알았나를 고른다 +- 값: 첫 질문은 이 변경이 내 작업에 영향을 주는지(관계있다/관계없다/잘 모르겠다)이고, 관계있다면 미리 알았는지(알았다/몰랐다)를 묻는다 +- 이유: 주 지표인 몰랐다 수를 세려면 두 질문이 모두 필요하고, 바로 판단하기 어려운 사람이 Esc나 Chat about this 대신 고를 보기가 필요하다 - 적용 범위: 코드: packages/mcp/src/tools/respond* / 화면: 터미널 알림, 나에게 온 변경 -- 근거: PRD-04 기능 3 · 사일로 OKR과 지표 +- 근거: PRD-04 기능 3 · 사일로 OKR과 지표 · Linear SIL-71 사내 테스트 의견과 10/12 호윤님 회의 +- 항상 적용: 아니오 +- 상태: 사용 +- 버전: 2 + +### notify.answer.unsure-later +- 제목: "잘 모르겠다"를 고르면 답하지 않은 변경으로 남겨 나중에 답한다 +- 값: 담당자가 잘 모르겠다를 고르면 답으로 기록하지 않고 그 확인할 변경을 대기로 남긴 채 다음 변경으로 넘어간다 +- 이유: 바로 판단하기 어려운 변경을 억지로 답하게 하면 관계·알았나 답이 틀려지고, 결정한 사람과 이야기한 뒤 답할 시간이 필요하다 +- 적용 범위: 화면: 터미널 알림, 나에게 온 변경 / 코드: packages/notify/src/terminal/** / 코드: apps/mcp-server/src/hooks/** +- 근거: Linear SIL-71 사내 테스트 의견과 10/12 호윤님 회의 · 부건님 시안 (10/13) - 항상 적용: 아니오 - 상태: 사용 - 버전: 1