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`(답함·대체됨·자동 닫힘)를 돌려줘요. "잘 모르겠다"는 `unsure: true`로 불러서 DB에 쓰지 않고 대기로 남겨요 (notify.answer.unsure-later). "관계있다"로 저장되면 `rereadStandardId`를 돌려줘서 AI가 그 결정 사항을 `get_standard`로 다시 읽어요.
`respond`는 DB까지 가기 전에 `@sil/notify`의 `checkAnswer`로 먼저 검사해서 빠진 칸을 한국어로 알려 줘요. 이미 닫힌 변경이면 `closedStatus`(답함·대체됨·자동 닫힘)를 돌려줘요. "관계있다"로 저장되면 `scopeCheck`(그 버전의 적용 범위와 안내)도 돌려줘요. AI는 적용 범위부터 훑어 새 값과 다른 곳을 표(위치 | 코드 | 지금 값)로 알려 주기만 하고 고치지 않아요. 다른 곳이 없으면 없다고 말하고, 적용 범위가 없거나 경로를 하나도 못 찾으면 그렇다고 말한 뒤 사람이 원할 때만 제목과 값으로 찾아요 (SIL-98). "잘 모르겠다"는 `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
4 changes: 2 additions & 2 deletions apps/mcp-server/src/hooks/notify-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ const toolName = (name: string) => `mcp__${SERVER_INFO.name}__${name}`;
*/
function buildGuide(recall: boolean): string {
const after = recall
? '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면 글을 쓰지 않고 끝낸다. 사용자의 입력은 답하기를 다시 부른 것이라 따로 처리할 요청이 없다.'
: '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면 사용자의 원래 요청을 처리한다. 이때도 확인한 변경에 대해서는 따로 말하지 않는다.';
? '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면, respond 결과에 scopeCheck 가 있던 변경마다 그 guide 대로 적용 범위를 훑어 알려 준 뒤 끝낸다. 그 밖에는 글을 쓰지 않고 끝낸다. 사용자의 입력은 답하기를 다시 부른 것이라 따로 처리할 요청이 없다.'
: '모든 변경에 답했거나(잘 모르겠다로 남긴 것 포함) 사용자가 질문을 닫았으면, respond 결과에 scopeCheck 가 있던 변경마다 그 guide 대로 적용 범위를 훑어 새 값과 다른 곳을 먼저 알려 준다(고치지는 않는다). 그다음 사용자의 원래 요청을 처리한다. 그 밖에는 확인한 변경에 대해 따로 말하지 않는다.';
return `[확인할 변경 규칙] 사용자의 요청을 처리하기 전에 아래 순서를 먼저 끝낸다.
변경 상세와 기록 결과는 hooks가 사용자 화면에 이미 직접 보여 준다. 그러니 상세 내용, 요약, 표, 인사말, 진행 설명을 직접 쓰지 않는다. 서버 안내문 8번의 "표로 보여 주기"도 하지 않는다. 글은 한 글자도 출력하지 말고 도구만 부른다.
1. ${toolName('list_pending')} 을 부른다.
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를 부르고 다음 변경으로 넘어가요. 잘 모르겠다를 고르면 더 묻지 않고 respond를 unsure: true로 불러요(기록되지 않고 대기로 남아요). 다 끝나면 원래 하려던 일을 이어서 해요.',
'8. 대기 중인 확인할 변경이 있으면 하나씩 차례로 다뤄요 (notify.terminal.one-change). 결정 사항 제목, 이전 값, 지금 값, 이유, 바꾼 사람과 시각, 내 이슈를 표로 보여 주고(notify.terminal.show-fields), 바로 이어서 그 항목의 questions를 AskUserQuestion으로 글자 그대로 물어요. 고른 답으로 respond를 부르고 다음 변경으로 넘어가요. 잘 모르겠다를 고르면 더 묻지 않고 respond를 unsure: true로 불러요(기록되지 않고 대기로 남아요). 관계있다로 기록되어 respond 결과에 scopeCheck가 오면, 그 guide대로 적용 범위부터 훑어 새 값과 다른 곳을 알려 주기만 해요. 다 끝나면 원래 하려던 일을 이어서 해요.',
// 이슈 반영 표시는 사람이 말했을 때만 해요. AI가 코드를 보고 짐작하지 않아요 (SIL-93, notify.check.reflected)
'9. 이슈 반영: 사람이 관계있다로 답한 변경을 "이슈에 반영했어요"라고 말하면 mark_reflected로 표시해요. "이슈 반영"만 입력하면 hooks가 목록과 질문을 알려 주니 그대로 따라요. 표시를 풀어 달라고 하면 reflected를 false로 불러요. 코드를 보고 반영했다고 짐작해서 표시하지 않아요.',
].join('\n');
Expand Down
58 changes: 57 additions & 1 deletion apps/mcp-server/src/notify-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function versionRow(version: number, value: string, changedBy: string) {
reason: version === 1 ? '알림 창에서 잘린다' : 'Windows 알림 창에서 잘림',
always_on: false,
evidence: null,
scope: null,
scope: null as Record<string, string[]> | null,
changed_by: changedBy,
changed_by_actor: 'ai',
ai_detected: false,
Expand Down Expand Up @@ -414,6 +414,17 @@ describe('respond', () => {
pendingCheckId: 'c-5',
issueKey: '#5',
rereadStandardId: STANDARD,
// 데모 예시에는 적용 범위가 없어서, 없다고 말한 뒤 제목과 값으로 찾게 해요 (SIL-98)
scopeCheck: {
standardId: STANDARD,
version: 2,
title: '앱 알림 제목은 공백 포함 32자 이하로 쓴다',
value: '공백 포함 32자 이하',
scope: null,
guide: expect.stringContaining(
'바뀐 결정 사항에 적용 범위가 적혀 있지 않아 다른 곳을 찾지 못했어요. 필요하신 경우 제목과 값으로 코드를 찾아볼게요.',
),
},
});
expect(fake.rpcCalls).toEqual([
{
Expand All @@ -430,6 +441,51 @@ describe('respond', () => {
]);
});

it('관계있다면 그 버전의 적용 범위를 단서로 함께 줘요 (SIL-98)', async () => {
const tables = demoTables();
const scope = {
code: ['apps/web/src/notify/**'],
api: [],
screen: ['앱 알림'],
doc: [],
};
tables.standard_versions = tables.standard_versions.map((row) =>
row.version === 2 ? { ...row, scope } : row,
);
installFakeDb(tables);
const result = await call(await connectAs('민수'), 'respond', {
pendingCheckId: 'c-5',
affected: true,
known: false,
});
expect(result.json().scopeCheck).toMatchObject({ version: 2, scope });
// 다른 곳을 알려 주는 표 모양과 없을 때 고정 문장 (부건님 SIL-98 의견 2, 3)
const guide = (result.json().scopeCheck as { guide: string }).guide;
expect(guide).toContain('"위치 | 코드 | 지금 값"');
expect(guide).toContain('"고치려면 말씀해 주세요."');
expect(guide).toContain('"외 N곳"');
expect(guide).toContain(
'"적용 범위 안에서 바뀐 변경사항과 다른 곳은 없어요."',
);
// 경로를 못 찾으면 말하고, 사람이 원할 때만 찾아봐요 (notify.answer.scope-missing v2)
expect(guide).toContain('적힌 경로 <경로>를 찾지 못했어요. 필요하신 경우');
expect(guide).toContain('사람이 찾아 달라고 할 때만');
// list_pending에도 같은 적용 범위가 들어 있어요 (화면에는 그리지 않고 AI에게만). 방금 답해서 닫힌 것까지 봐요
const list = await call(await connectAs('민수'), 'list_pending', {
includeClosed: true,
});
expect(list.json().items).toMatchObject([{ standard: { scope } }]);
});

it('관계없다면 적용 범위 단서를 주지 않아요', async () => {
const result = await call(await connectAs('신지'), 'respond', {
pendingCheckId: 'c-7',
affected: false,
unaffectedReason: '내 작업 범위가 아니에요',
});
expect(result.json().scopeCheck).toBeUndefined();
});

it('관계없다는 이유를 저장하고, 알았나는 비워요', async () => {
await call(await connectAs('신지'), 'respond', {
pendingCheckId: 'c-7',
Expand Down
67 changes: 65 additions & 2 deletions apps/mcp-server/src/tools/notify/respond.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { isSilError } from '@sil/core';
import { listPendingChecks, saveAnswer } from '@sil/core/node';
import type { StandardScope, StandardVersion } from '@sil/core';
import {
getStandardHistory,
listPendingChecks,
saveAnswer,
} from '@sil/core/node';
import {
checkAnswer,
checkUnsure,
Expand All @@ -17,6 +22,47 @@ async function findCheck(pendingCheckId: string) {
return found ? (toNotifyCheck(found) ?? undefined) : undefined;
}

/**
* 관계있다로 답한 뒤 AI가 할 일 (SIL-98, 10/16 민수님 결정, notify.answer.scope-hint).
* 서비스는 단서(적용 범위)까지만 줘요. 코드를 찾고 고치는 건 에이전트, 고칠지 정하는 건 사람이에요 (10/14 호윤님).
*/
export const SCOPE_GUIDE =
'모든 질문이 끝난 뒤 원래 요청을 처리하기 전에, scope.code 경로부터 훑어서 지금 결과물이 value와 다른 곳을 찾는다. 고치지는 않는다. 사람이 고치라고 할 때만 고친다. ' +
// 다른 곳을 알려 주는 모양 (부건님 SIL-98 의견 3, 10/16)
'다른 곳이 있으면 결정 사항마다 표 하나로 알려 준다. 표 위에 결정 사항 title을 한 줄 쓰고(ID는 쓰지 않는다), 칸은 "위치 | 코드 | 지금 값"이다. ' +
'위치는 파일:줄로 한 곳에 한 줄씩 쓰고 같은 파일이라도 줄이 다르면 나눈다. 코드 칸은 코드 원문 대신 쉬운 말로 짧게 쓴다(예: 제목을 40자로 자름). 지금 값 칸은 value를 짧게 쓴다. ' +
'최대 5줄까지 쓰고, 넘으면 표 아래에 "외 N곳"을 붙인다. 표 아래에는 "고치려면 말씀해 주세요."를 그대로 붙이고, "고칠까요?"처럼 묻지 않는다. ' +
// 다른 곳이 없을 때 고정 문장 (부건님 SIL-98 의견 2)
'다른 곳이 없으면 "적용 범위 안에서 바뀐 변경사항과 다른 곳은 없어요."라고만 말한다. ' +
// 적용 범위가 없거나 경로를 못 찾을 때: 말하고, 사람이 원할 때만 찾아봐요 (notify.answer.scope-missing v2, 부건님 SIL-98 의견 1)
'scope가 null이거나 비어 있으면 "바뀐 결정 사항에 적용 범위가 적혀 있지 않아 다른 곳을 찾지 못했어요. 필요하신 경우 제목과 값으로 코드를 찾아볼게요."라고만 말한다. ' +
'적힌 경로를 하나도 찾지 못하면 "적힌 경로 <경로>를 찾지 못했어요. 필요하신 경우 제목과 값으로 코드를 찾아볼게요."라고만 말한다. ' +
'적힌 경로 중 일부만 없으면 찾은 경로로 표(또는 다른 곳이 없다는 문장)를 먼저 보여 주고, 그 아래에 "적힌 경로 <경로>를 찾지 못했어요."만 붙인다. ' +
'어느 경우든 사람이 찾아 달라고 할 때만 title과 value로 코드를 찾아본다. 조용히 넘어가지 않는다.';

/** 그 결정 사항 버전(제목·값·적용 범위). 읽지 못하면 null로 두고 안내대로 찾게 해요 */
async function versionOf(
standardId: string,
version: number,
): Promise<StandardVersion | null> {
try {
const history = await getStandardHistory(standardId);
return history?.versions.find((v) => v.version === version) ?? null;
} catch {
return null;
}
}

/** 적용 범위에 적힌 것이 하나라도 있는지 */
function hasScope(scope: StandardScope | null): scope is StandardScope {
return (
scope !== null &&
[scope.code, scope.api, scope.screen, scope.doc].some(
(list) => list.length > 0,
)
);
}

export const respondTool = defineTool({
name: 'respond',
title: '변경에 답하기',
Expand All @@ -26,6 +72,7 @@ export const respondTool = defineTool({
'잘 모르겠다를 고르면 더 묻지 않고 unsure: true로만 불러요(affected 없이). 답으로 기록하지 않고 대기로 남겨요. ' +
'답을 지어내지 말고 사람이 고른 그대로 넣어요. 관계없다의 이유는 고른 보기의 label을, 직접 쓴 글이면 그 글을 unaffectedReason에 넣어요. ' +
'ok가 true이고 rereadStandardId가 있으면 그 결정 사항을 get_standard로 다시 읽고 이후 작업에 따라요. ' +
'scopeCheck가 있으면 그 guide대로 적용 범위부터 훑어서 새 값과 다른 곳을 알려 주기만 해요. ' +
'ok가 false면 message를 그대로 보여 줘요. closedStatus가 auto_closed면 "원래대로 돌아와서 답하지 않아도 돼요"라고 알리고 다음 변경으로 넘어가요.',
inputSchema: z.object({
pendingCheckId: z
Expand Down Expand Up @@ -103,14 +150,30 @@ export const respondTool = defineTool({
channel: 'terminal',
elapsedSeconds: input.elapsedSeconds ?? null,
});
return {
const result = {
ok: true,
answerId: saved.answerId,
pendingCheckId: saved.pendingCheckId,
issueKey: saved.issueKey,
// 관계있다면 에이전트가 바뀐 결정 사항을 다시 읽어요 (notify.answer.next-run-context)
rereadStandardId: checked.affected ? (check?.standardId ?? null) : null,
};
if (!checked.affected || !check) return result;
// 관계있다면 어디부터 볼지 단서(적용 범위)를 함께 줘요 (SIL-98)
const version = await versionOf(check.standardId, check.version);
const scope = version?.scope ?? null;
return {
...result,
scopeCheck: {
standardId: check.standardId,
version: check.version,
// 표 위에 쓸 제목과 "지금 값" 칸에 쓸 값이에요
title: version?.title ?? null,
value: version?.value ?? null,
scope: hasScope(scope) ? scope : null,
guide: SCOPE_GUIDE,
},
};
} catch (error) {
// 검사와 저장 사이에 다른 곳(앱)에서 먼저 답했거나 닫혔으면, 지금 상태로 알려 줘요 (notify.answer.one-place)
if (isSilError(error) && error.code === 'PENDING_CLOSED') {
Expand Down
1 change: 1 addition & 0 deletions packages/notify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const result = await listPending(store, { recipient: '민수' });
- 답함과 대체됨은 닫힌 지 7일이 지나면 목록에서 빠져요 (notify.list.closed-7-days). 기록은 지우지 않아요 (data.answer.keep-records). 대기는 오래돼도 계속 보여요.
- 최신 것이 먼저 와요.
- 기준 버전이나 이슈를 찾지 못하면 빈 목록 대신 오류를 내요 (notify.error.not-empty).
- `standard.scope`: 그 버전의 적용 범위(core `StandardScope`, 없으면 null). 터미널 화면에는 그리지 않고 AI에게만 줘요 (SIL-98).
- `previousAnswer`: 같은 이슈에서 이 결정 사항의 이전 버전에 한 마지막 답이에요. `affected`가 true면 화면이 "지난번 변경을 반영하셨다면 다시 고쳐야 해요"를 붙여요 (SIL-77).
- 저장소 없이 데이터만으로 만드는 `buildPendingList`도 있어요. MCP 도구 `list_pending`은 DB에서 모은 데이터로 이 함수를 불러요.

Expand Down
1 change: 1 addition & 0 deletions packages/notify/src/coreData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function toVersionRecord(
source: version.source,
aiDetected: version.aiDetected,
isRevert: version.isRevert,
scope: version.scope,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/notify/src/listPending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('listPending', () => {
source: 'ai_chat',
aiDetected: false,
isRevert: false,
scope: null,
},
issue: {
key: '#5',
Expand Down
7 changes: 7 additions & 0 deletions packages/notify/src/listPending.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { StandardScope } from '@sil/core';
import type {
AnswerChannel,
NotifyData,
Expand Down Expand Up @@ -82,6 +83,11 @@ export interface PendingItemStandard {
source: StandardSource;
aiDetected: boolean;
isRevert: boolean;
/**
* 적용 범위. 터미널 화면에는 보여 주지 않고 AI에게만 줘요 (SIL-98, 10/16 민수님 결정).
* 관계있다로 답한 뒤 AI가 이 경로부터 훑어서 지금 결과물이 새 값과 다른 곳을 알려 줘요. 없으면 null
*/
scope: StandardScope | null;
}

export interface PendingItemIssue {
Expand Down Expand Up @@ -185,6 +191,7 @@ export function buildPendingList(
source: current.source,
aiDetected: current.aiDetected,
isRevert: current.isRevert,
scope: current.scope ?? null,
},
issue: { key: issue.key, title: issue.title, status: issue.status },
answer: answer
Expand Down
1 change: 1 addition & 0 deletions packages/notify/src/terminal/screens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function demoItem(overrides: Partial<PendingItem> = {}): PendingItem {
source: 'ai_chat',
aiDetected: false,
isRevert: false,
scope: null,
},
issue: {
key: '#5',
Expand Down
6 changes: 6 additions & 0 deletions packages/notify/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* 공통 타입 PR(#6, @sil/core)이 머지되면 여기 있는 Issue, PendingCheck는 그쪽 타입으로 바꿔요.
* 지금은 이 패키지가 혼자 돌 수 있게 필요한 칸만 두었어요.
*/
import type { StandardScope } from '@sil/core';

/** 기준 ID. '영역.주제.규칙' 모양. 예: 'app.notify.title-length' */
export type StandardId = string;
Expand Down Expand Up @@ -108,6 +109,11 @@ export interface StandardVersionRecord {
aiDetected: boolean;
/** 예전 버전 값으로 되돌린 버전이면 true */
isRevert: boolean;
/**
* 적용 범위 (코드 / API / 화면 / 문서). 모양은 core의 StandardScope 그대로예요 (SIL-98).
* 관계있다로 답한 뒤 AI가 어디부터 볼지 알려 주는 단서예요. 임시 저장소 예시 데이터에는 없을 수 있어요
*/
scope?: StandardScope | null;
}

/** 답한 곳: 터미널 / 앱 (착수 안내 v2. Slack은 만들지 않아요) */
Expand Down
Loading
Loading