diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4dfee01..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "permissions": { - "ask": ["mcp__haribo__save_standard", "mcp__haribo__revert"] - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-preview.ts\"" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-conflicts.ts\"" - }, - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-notice.ts\"" - } - ] - }, - { - "matcher": "mcp__haribo__save_standard", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-saved.ts\"" - } - ] - } - ], - "PostToolUseFailure": [ - { - "matcher": "mcp__haribo__save_standard", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-saved.ts\"" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "mcp__haribo__save_standard", - "hooks": [ - { - "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-guard.ts\"" - } - ] - } - ] - } -} diff --git a/CLAUDE.md b/CLAUDE.md index 974ffb2..f8cc1ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,29 +11,9 @@ - 팀 위키 위치: team-wiki - 작업을 시작하기 전에 팀 위키의 INDEX.md를 읽는다. "항상 적용되는 기준"은 모든 작업에서 지킨다. - 이번 작업과 관련된 영역 파일만 열어 본다. 위키 전체를 읽지 않는다. -- 팀 위키에 있는 기준과 다르게 만들게 되면, 만들기 전에 사용자에게 "팀 기준을 바꾸는 건지" 먼저 묻는다 (T1, SIL-66). auto mode에서도 같다. - 문구는 `packages/t1/src/copy.ts`의 `T1_COPY`를 글자 그대로 쓴다(바꿔 말하지 않는다). 선택지는 거기 있는 것만 쓰고 새로 만들지 않는다. - 1. haribo `get_standard`로 지금 영역·제목·값·`alwaysOn`·`currentVersion`과 적용 범위(`versions[0].scope`)를 읽는다. - - 1-1. 되돌림 확인 (SIL-104, `detect.change.revert-mark`): `get_standard`는 `historyLimit: 50`으로 읽고, 바꿀 값을 지금 버전이 아닌 예전 버전과 비교한다. 띄어쓰기·끝 마침표·전각 글자만 다르거나, 글자는 달라도 뜻이 같으면(숫자·범위·조건·대상이 모두 같을 때만) 되돌림이다. 되돌림이면 바꿀 값을 그 버전의 값으로 글자 그대로 바꾸고(저장은 그대로 `save_standard`, DB가 되돌림으로 표시한다), 2의 화면에 그 버전을 `revert`로 더한다(되돌림 화면이 된다. 어긋나는 결정 사항이 없으면 보라 막대, 있으면 노란 막대). 2·5의 선택 창 질문은 되돌림이 아닐 때와 같다. - 2. 다른 결정 사항 화면: `trigger`(작업 중에 알아챘으면 `"agent"`, 8의 직접 요청이면 `"direct"`), 부탁받은 작업이나 요청을 한 줄로 정리한 `request`, 바꾸려는 결정 사항(`kind: "change"`, 맨 앞)·바꾸면 함께 어긋나는 다른 결정 사항(`kind: "mismatch"`)을 JSON으로 `node apps/detect/src/t1/conflicts.ts <<'EOF' … EOF`에 넘겨 실행한다(화면은 hook이 띄운다). 항목마다 `id`·`title`·`kind`·`why`를 넣고(`why`는 어떻게 바뀌는지만 짧게 쓴 한 문장. `change`는 지금 값과 바꿀 값만 써서 "40자 이하로 정한 길이를 32자 이하로 다시 정해요"처럼, `mismatch`는 무엇이 어긋나는지만 써서 "결정 사항 제목(최대 40자)을 그대로 쓰면 32자에서 잘려요"처럼 쓴다. "지금 결정 사항은 …인데, 부탁하신 작업은 …"처럼 작업을 다시 설명하지 않는다. SIL-107), 낱말만 겹치는 결정 사항은 넣지 않는다. 1-1에서 되돌림이면 `kind: "change"` 항목에 지금 버전 `version`(`currentVersion`)을 넣고, `get_standard`로 읽은 되돌아갈 버전의 `version`·`title`·`reason`·`changedBy`·`createdAt`을 `revert`로 글자 그대로 더한다. 화면을 요약하거나 "화면을 띄웠어요"처럼 설명하는 글을 쓰지 않고 바로 다음으로 간다(③도 같다). 그다음 `ask`를 AskUserQuestion으로 묻는다(질문은 `ask.question`, header는 `ask.header`, 선택지는 `yes`·`no`의 label·description 그대로, 질문에 화면 내용을 다시 적지 않는다). 선택지 대신 글로 답하면 `ask.typedReply`를 쓰고 같은 질문을 다시 묻는다. - 3. 아니요면 저장하지 않고 `notChanged`를 보여 준 뒤 작업을 멈추고 기다린다. 지금 값대로도 코드를 쓰지 않는다. 사람이 "40자로 작업해 줘"처럼 어떻게 할지 다시 말할 때만 그대로 진행한다. 아니요는 그 프롬프트로 시작한 작업 하나에만 적용된다. 새 프롬프트로 다시 결정 사항과 다른 값을 쓰게 되면 새 작업이라 T1을 1부터 다시 한다(예외 11). - 4. 예면 이유를 정한다. 사람이 대화에서 한 말로 이유를 알 수 있으면 그 말을 한 문장으로 정리한다. 알 수 없으면 `reasonPrompt`를 글로 묻고(AskUserQuestion이 아님) 답을 한 문장으로 정리한다. 근거 없이 지어내지 않는다. - 5. ③ 등록될 모습: 등록할 값(`id`·`title`·`value`·`reason`·`alwaysOn`, 그리고 바꾸는 사람 이름 `changedBy` = 이 세션 `list_pending`의 `recipient`, 모르면 뺀다)을 JSON으로 `node apps/detect/src/t1/preview.ts <<'EOF' … EOF`에 넘겨 실행한다(화면은 hook이 띄운다). 그다음 `preview.confirm`을 AskUserQuestion으로 묻는다(header는 `preview.header`, 선택지는 `register`·`edit`·`cancel`의 label·description 그대로, 질문과 설명에 ③ 내용을 다시 적지 않는다. 명령 출력의 "알림 받는 이슈"가 1개 이상이면 `register` 대신 `registerWithIssues`를 쓴다). 수정하기면 `editPrompt`를 글로 묻고 고친 뒤 5를 다시 한다. 취소하기면 저장하지 않고 `cancelled`를 보여 준 뒤 하던 작업을 멈추고 사람의 말을 기다린다. `cancelled`는 다른 작업 중에 T1이 떴으면 `withTask`, 결정 사항을 바꿔 달라는 것 자체가 요청이었으면 `noTask`를 쓴다. - 6. 등록하기면 haribo `save_standard`로 저장한다. 저장되면 hook이 ④ 등록 완료를 띄우니 ④를 다시 쓰거나 설명하지 않는다. 하던 작업이 있으면 `saved.next.withTask`를 쓰고 바꾼 값으로 그 작업을 이어가고, 결정 사항을 바꿔 달라는 것 자체가 요청이었으면 `saved.next.noTask`를 쓴다. `baseVersion`은 1에서 읽은 `currentVersion`, `alwaysOn`과 `scope`는 지금 값 그대로(scope를 비우면 적용 범위가 지워진다. 지금 값이 null이면 칸을 뺀다), `source`는 `ai_chat`, `aiDetected`는 `true`(직접 요청이면 `false`) (`packages/t1/src/save-input.ts`와 같은 값, D61). 저장 결과의 `changelogLine`을 team-wiki/CHANGELOG.md 맨 아래에 그대로 붙인다(work.wiki.changelog). 위키 파일의 값·버전은 고치지 않는다 (위키 문서 버전과 DB 버전은 별개). - 7. T1 선택 창을 Esc로 닫으면 취소하기와 같다. 다음 말이 오면 먼저 `cancelled`(5와 같은 기준으로 `withTask`·`noTask`)를 보여 주고 저장하지 않는다. "계속해 줘"처럼 어떻게 할지 분명하지 않으면 작업하지 않고 기다린다. 결정 사항을 바꾸라는 말이면 T1을 1부터 다시 한다. - 8. 직접 요청 (SIL-94, `detect.ask.direct-request-skip-warn`): 사람이 "결정 사항"이나 "위키"를 바꿔 달라고 분명히 말했을 때만 직접 요청으로 본다(10/15 임시 기준, PM과 확인 중). "32자로 자르는 함수를 만들어 줘"처럼 코드만 말하면 직접 요청이 아니라 1부터 한다. - - 결정 사항을 vN(또는 "이전 버전" = `currentVersion - 1`)으로 되돌려 달라는 요청도 직접 요청이다. 1로 읽고, 바꿀 제목·값을 vN의 제목·값으로 글자 그대로 정한 뒤 아래 "기존 결정 사항이 있으면"과 같이 T1 화면을 거친다(1-1에서 되돌림이 되어 2에서 `ask`를 먼저 묻는다). 저장은 `save_standard`로 한다(③·④ hook이 `save_standard`에만 붙어 있어서 `revert`는 쓰지 않는다). 지금 값이 이미 vN의 값과 같으면(N이 지금 버전일 때도) 화면을 띄우지 않고 9의 "되돌릴 값이 지금과 같음"을 띄운다. - - 기존 결정 사항이 있으면: 1로 읽고, 2의 다른 결정 사항 화면은 `trigger: "direct"`로 띄우되 `ask`는 묻지 않고 4로 간다(3도 건너뛴다). 5의 선택 창 질문은 `preview.confirm` 대신 `direct.confirm`. 단 1-1에서 되돌림이면 직접 요청이어도 2의 `ask`를 질문만 `direct.revertAsk`로 바꿔 묻고(아니요면 3), 예면 4로 가며 5의 질문은 `preview.confirm`이다 (`detect.ask.direct-request-skip-warn` v2). - - 기존 결정 사항이 없으면(`search_standards`로 찾아도 없음): 2의 다른 결정 사항 화면을 `trigger: "direct"`, `conflicts: []`로 띄우고(초록 막대 한 줄) `ask`는 묻지 않고 4로 간다. 5의 JSON에 `"isNew": true`를 더한다(③ 제목 줄에 "새 결정 사항" 표시, 받는 이슈는 0개). 5의 선택 창 질문은 `preview.confirm`("이대로 등록할까요?")이고 `direct.confirm`은 쓰지 않는다(바뀌는 기존 결정 사항이 없어서). ID는 `data.standard.id-format`대로 정하고, `alwaysOn`은 사람이 말하지 않았으면 `false`로 ③에 보여 준다. 6에서 `baseVersion`은 `0`, `scope` 칸은 뺀다. - - 6에서 `aiDetected`는 `false`, 취소하기·Esc면 `cancelled.noTask`를 쓴다. - 9. 예외 안내(10/14 확정): 아래 값을 JSON으로 `node apps/detect/src/t1/notice.ts <<'EOF' … EOF`에 넘겨 실행한다(화면은 hook이 띄운다. 안내를 다시 쓰거나 설명하지 않는다). - - 조사 실패(예외 3): T1 중에 `search_standards`·`get_standard`가 실패하면 "다른 결정 사항 없음"으로 넘어가지 않는다. `{"kind":"searchFailed","trigger":…}`(2와 같은 trigger)를 띄우고 하던 작업이나 등록을 멈춘 뒤 사람의 말을 기다린다. - - 완전히 같음(충돌 3): 직접 요청한 값이 그 결정 사항의 지금 값과 같으면(같은 ID·같은 값) `{"kind":"same","existing":{"id","title","value"}}`를 띄우고 선택지 없이 끝낸다. 저장하지 않는다. - - 되돌릴 값이 지금과 같음(SIL-104): vN으로 되돌려 달라는데 지금 값이 이미 vN의 값과 같으면 `{"kind":"revertSame","version":N,"versionValue":vN의 값,"existing":{"id","title","value"}}`(existing은 지금 버전)를 띄운다. 저장하지 않고, 화면 뒤에는 `notices.revertSame.next` 한 줄만 글자 그대로 쓴 뒤(다른 말·영어 설명을 덧붙이지 않는다) 사람의 말을 기다린다. - - 뜻이 같음(충돌 3): 새 결정 사항을 만들어 달라는데 다른 ID의 결정 사항이 같은 뜻이면 `{"kind":"similar","existing":{…}}`를 띄우고 `similarAsk`를 AskUserQuestion으로 묻는다. 예면 새 결정 사항으로 4부터 하고(`isNew`), 아니요면 `{"kind":"similarDeclined"}`를 띄우고 끝낸다. - - 저장하는 사이 바뀜(예외 7): `save_standard`가 `VERSION_CONFLICT`로 거절되면 저장 도구 hook이 노란 막대 안내를 바로 띄운다(`notice.ts`를 실행하지 않는다). 1부터 다시 한다. `get_standard`로 새 `currentVersion`을 읽어 2의 다른 결정 사항 화면을 다시 띄우고, 사람이 이미 고른 예와 정한 이유는 그대로 두고 5(③)부터 다시 묻는다. - - 어느 경우든 6에서 저장하지 않았으면 결정 사항과 다른 값으로 코드를 쓰지 않는다. -- (T1로 저장한 경우는 위 6을 따르고 이 줄은 하지 않는다) 기준을 바꾸기로 하면: 해당 기준의 값·이유·근거를 고치고 버전을 1 올린 뒤, CHANGELOG.md 맨 아래에 한 줄을 남긴다. +- 팀 위키에 있는 기준과 다르게 만들게 되면, 만들기 전에 사용자에게 "팀 기준을 바꾸는 건지" 먼저 묻는다 (T1, SIL-66, work.wiki.ask-before-change). auto mode에서도 같다. +- 절차는 haribo MCP 서버가 알려 준다: 언제 부르는지는 서버 안내문 10번, 부르기 전에 할 일은 show_standard_check 도구 설명, 그다음 할 일과 물을 질문은 hooks가 넘긴다(BDT-108). hooks는 pnpm mcp:register로 깐다. +- (T1로 저장한 경우는 저장 결과의 `changelogLine`을 붙이고 이 줄은 하지 않는다) 기준을 바꾸기로 하면: 해당 기준의 값·이유·근거를 고치고 버전을 1 올린 뒤, CHANGELOG.md 맨 아래에 한 줄을 남긴다. 형식: 날짜 시각 | 누가 | 기준 ID 이전→새 버전 | 이전 값 → 새 값 | 이유 | 출처(코드/AI 대화/Figma/회의/Slack) - 관련 기준이 없어서 새로 정했다면 기준-작성-원칙.md대로 새 기준을 추가하고 CHANGELOG에 남긴다. - `잠금: 예`인 기준은 고치지 않고 사용자에게 알린다. @@ -76,8 +56,8 @@ - `supabase/migrations` DB 표·함수·권한, `supabase/tests` DB 규칙 검사 (SQL) - `apps/cli` `sil` 명령어. `src/commands/`에 파일 하나씩, `index.ts` 배열에 등록 - `apps/web` 웹 앱(PWA, Vite, D50). 화면은 `src/screens/`, 화면↔데이터 약속은 `src/api/types.ts`, 설치 정보와 서비스 워커는 `public/` -- `apps/detect` 바뀐 기준 찾기 (`@sil/detect`, 경하님 SIL-66). 에이전트 경로 T1의 Bash 명령과 hooks는 `src/t1/`. PR 경로는 아직 없음 -- `packages/t1` T1 화면 모양·문구·저장 값(`@sil/t1`, 경하님 BDT-108). `apps/detect`와 MCP 서버가 가져다 써요 +- `apps/detect` 바뀐 기준 찾기 (`@sil/detect`, 경하님 SIL-66). PR 경로는 `src/pr/`. 에이전트 경로 T1은 `packages/t1`과 MCP 서버(`show_standard_check`, `hooks/t1-hooks.ts`)로 옮겼다 (BDT-108) +- `packages/t1` T1 화면 모양·문구·저장 값(`@sil/t1`, 경하님 BDT-108). MCP 서버가 가져다 써요 ## 코드 규칙 diff --git a/apps/detect/README.md b/apps/detect/README.md index 0679f77..4682cd8 100644 --- a/apps/detect/README.md +++ b/apps/detect/README.md @@ -11,36 +11,16 @@ ## 에이전트 경로 (T1) -에이전트는 루트 `CLAUDE.md` "팀 위키 규칙"의 순서대로 움직여요. 코드는 문구와 저장 값만 들고 있어요. - -| 파일 | 내용 | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@sil/t1` (`packages/t1`) | T1 문구(`copy.ts`), 저장 값(`save-input.ts`), 화면 모양(`screens.ts`, 칸 계산 `text.ts`), ③ 받는 이슈(`issues.ts`). MCP 서버도 가져다 써서 패키지로 옮겼어요 (BDT-108). [t1/README](../../packages/t1/README.md) | -| `src/t1/conflicts.ts` | 다른 결정 사항 화면 값을 임시 파일(`haribo-t1-conflicts-.json`)에 들고 있는 명령. hook이 띄운 뒤 바로 지워요 (경우 1·2) | -| `src/t1/notice.ts` | 예외 안내(조사 실패, 완전히 같음, 뜻이 같음, 되돌릴 값이 지금과 같음) 값을 임시 파일(`haribo-t1-notice-.json`)에 들고 있는 명령. hook이 띄운 뒤 바로 지워요 (10/14 확정 예외) | -| `src/t1/preview.ts` | ③ 등록될 모습 값을 임시 파일(운영체제 임시 폴더 `haribo-t1-pending-<결정 사항 ID>.json`, 결정 사항마다 따로)에 들고 있는 명령. 에이전트가 터미널로 불러요 | -| `src/t1/pending.ts` | 임시 값 쓰기·읽기·지우기·비교. 시간으로 지우지 않고, 다음 ③이 덮어쓰거나 저장하면 지워요 | -| `src/t1/preview-command.ts` | Bash 명령이 `preview.ts`·`conflicts.ts` 실행인지 알아보는 규칙. 앞에 `cd "…" &&`를 붙이거나 전체 경로로 불러도 알아봐요. 패키지를 불러오지 않아서 설치 전에도 다른 명령에서 hook 오류가 나지 않아요 | -| `src/t1/hook-*.ts` | hooks (`.claude/settings.json`에 등록). 아래 표 | - -흐름: 바꿀 값이 예전 버전과 같거나 뜻이 같은지 먼저 봄(같으면 되돌림: 값을 그 버전 글자 그대로 바꾸고 아래 되돌림 화면으로) → `conflicts.ts` 실행(hook이 "부탁하신 작업이 기존 결정 사항과 달라요"와 변경·어긋남 결정 사항을 띄움) → 묻기(예/아니요) → 아니요면 작업을 멈추고 사람이 다시 말할 때까지 기다림 / 예면 이유(대화에서 알 수 있으면 정리, 아니면 글로 물음) → `preview.ts` 실행(③은 hook이 띄움) → "이대로 등록할까요?"(등록하기 / 수정하기 / 취소하기) → 등록하기면 저장하고 CHANGELOG에 한 줄 붙이기(위키 파일은 그대로). 선택 창을 Esc로 닫으면 취소하기와 같아요. - -직접 요청(SIL-94, `detect.ask.direct-request-skip-warn`): 사람이 "결정 사항"이나 "위키"를 바꿔 달라고 분명히 말했을 때만이에요(10/15 임시 기준, PM과 확인 중). "32자로 자르는 함수를 만들어 줘"처럼 코드만 말하면 위 흐름이에요. - -- 기존 결정 사항이 있으면: `conflicts.ts`를 `trigger: "direct"`로 실행(머리표 "요청하신 변경이 기존 결정 사항과 달라요") → 묻기(예/아니요)는 건너뛰고 이유 → ③ → "달라지게 되는 기존 결정 사항 내역을 확인했나요? 이대로 등록할까요?" -- 바꿀 값이 예전 버전과 같으면(되돌림): `conflicts.ts`에 변경의 지금 버전(`version`)과 되돌아갈 버전(`revert`)을 넘겨 보라 막대 "…이전 결정 사항과 동일해요", 기존 · 작업, 되돌림 버전 · 바꾼 사람과 시각 · 이유를 띄워요(부건님 시안 10/16). 어긋나는 결정 사항도 있으면 막대는 노란 "…기존 결정 사항과 달라요"이고 어긋남을 되돌림 위에 보여요. 그 뒤에 "기존 결정 사항과 다른 작업이에요. 그래도 진행할까요?"(직접 요청이면 "기존 결정 사항을 되돌리는 작업이에요. 그대로 진행할까요?", `direct.revertAsk`)를 예/아니요로 먼저 묻고, 예면 이유 → ③ → "이대로 등록할까요?" (`detect.ask.direct-request-skip-warn` v2) -- vN(또는 이전 버전)으로 되돌려 달라는 요청: 직접 요청과 같이 T1 화면을 거쳐요. 값을 vN 글자 그대로 넣고 위 되돌림과 같이 물어요. 저장은 `save_standard`. 지금 값이 이미 vN과 같으면 되돌리지 않고 `revertSame` 안내로 끝내기 -- 기존 결정 사항이 없으면: `conflicts.ts`를 `conflicts: []`로 실행(hook이 초록 막대 "요청하신 변경과 다른 결정 사항이 없어요"를 띄움) → 이유 → `preview.ts`에 `"isNew": true`(③ 제목 줄에 "새 결정 사항", 받는 이슈 없음) → "이대로 등록할까요?" → 새 결정 사항으로 저장 - -| hook | 언제 | 하는 일 | -| ------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `hook-preview.ts` | `preview.ts` 명령 직후 | ③을 띄워요 (위아래 선, 청록 굵은 제목, 항상 적용은 예일 때만 굵게). hook 글은 도구 **직후**에만 화면에 보여요 | -| `hook-conflicts.ts` | `conflicts.ts` 명령 직후 | 다른 결정 사항 화면을 띄우고 임시 값을 지워요 (노란 막대 머리표, 결정 사항 제목은 굵게) | -| `hook-guard.ts` | `save_standard` 직전 | ③을 보여 준 같은 결정 사항인데 값이 다르면 저장을 막아요. 다른 저장(새로 만들기 등)은 막지 않아요 | -| `hook-notice.ts` | `notice.ts` 명령 직후 | 예외 안내를 띄우고 임시 값을 지워요 (빨강·노랑·기본 글자색 막대) | -| `hook-saved.ts` | `save_standard` 직후(실패 포함) | ③을 거친 저장이 성공하면 ④ 등록 완료(초록 첫 줄, 제목·항상 적용, 보낸 이슈 수)를, 그사이 바뀌어 거절되면(`VERSION_CONFLICT`) 예외 7 안내(노란 막대)를 띄우고 저장한 결정 사항의 임시 값만 지워요 | - -안전장치로 `.claude/settings.json`의 `permissions.ask`에 `save_standard`와 `revert`를 넣어, 저장하기 전에 Claude Code가 한 번 더 허락을 받아요. auto mode에서도 뜨고, 허락 창에서 "Yes, and don't ask again"을 골라도 다음에 다시 물어요 (10/13 시험). +T1은 haribo MCP 서버로 옮겨서 어느 저장소에서나 떠요 (BDT-108). 이 패키지에는 T1 코드가 없어요. + +| 무엇 | 어디 | +| --------------------------- | ------------------------------------------------------------------------------------------- | +| 화면 모양, 문구, 저장 값 | `@sil/t1` ([packages/t1](../../packages/t1/README.md)) | +| 언제 부르는지 | 서버 안내문 10번 (`apps/mcp-server/src/instructions.ts`) | +| 부르기 전에 할 일과 넘길 값 | `show_standard_check` 도구 설명 (`apps/mcp-server/src/tools/detect/show-standard-check.ts`) | +| 화면 띄우기, 저장 전후 확인 | 서버 hooks ([mcp-server README "T1 결정 사항 확인 hooks"](../mcp-server/README.md)) | + +예전에는 이 저장소 `CLAUDE.md` T1 1~9번과 `src/t1/`의 Bash 명령·hooks로 움직였어요. 아직 없는 것: `detect.ask.rollback-if-applied`(이미 코드에 들어간 값 되돌리기). diff --git a/apps/detect/package.json b/apps/detect/package.json index 0507d15..834ff35 100644 --- a/apps/detect/package.json +++ b/apps/detect/package.json @@ -14,7 +14,6 @@ "dependencies": { "@anthropic-ai/sdk": "0.131.0", "@sil/core": "workspace:*", - "@sil/t1": "workspace:*", "zod": "^4.2.0" } } diff --git a/apps/detect/src/index.ts b/apps/detect/src/index.ts index 8154522..7b6cda6 100644 --- a/apps/detect/src/index.ts +++ b/apps/detect/src/index.ts @@ -1,18 +1,3 @@ -// T1 화면·문구·저장 값은 @sil/t1로 옮겼어요 (BDT-108). 지금 가져다 쓰는 곳을 위해 그대로 다시 내보내요 -export { - buildT1NewSaveInput, - buildT1SaveInput, - conflictsScreen, - previewScreen, - T1_COPY, - type CurrentStandard, - type T1Answer, - type T1Conflict, - type T1ConflictsInput, - type T1NewAnswer, - type T1PreviewValues, - type T1Trigger, -} from '@sil/t1'; export { PR_COPY, renderOutcome } from './pr/copy.ts'; export { runPrPath, diff --git a/apps/detect/src/t1/conflicts.ts b/apps/detect/src/t1/conflicts.ts deleted file mode 100644 index a7a0d2b..0000000 --- a/apps/detect/src/t1/conflicts.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * T1 다른 결정 사항 화면 값을 임시로 들고 있는 명령이에요. 화면은 이 명령 직후 hook(`hook-conflicts.ts`)이 띄워요. - * 한글이 깨지지 않게 값은 표준 입력(JSON)으로 받아요. 변경을 맨 앞에 둬요. - * trigger는 에이전트가 작업 중에 알아챘으면 agent(경우 1, SIL-66), 사람이 직접 요청했으면 direct(경우 2, SIL-94)예요. - * node apps/detect/src/t1/conflicts.ts <<'EOF' - * {"trigger":"direct","request":"앱 알림 제목 길이를 32자 이하로 바꾸기","conflicts":[ - * {"id":"app.notify.title-length","title":"…","kind":"change","why":"…"}, - * {"id":"notify.app-title.from-standard","title":"…","kind":"mismatch","why":"…"}]} - * EOF - * 되돌림이면 변경에 지금 버전(version)을 넣고, get_standard의 되돌아갈 버전을 revert로 더해요: - * {"id":"app.notify.title-length","title":"…","kind":"change","why":"…","version":3} - * "revert":{"version":2,"title":"…","reason":"…","changedBy":"부건","createdAt":"2026-10-08T05:13:00Z"} - */ -import { readFileSync } from 'node:fs'; -import { STANDARD_ID_PATTERN } from '@sil/core'; -import { T1_COPY, type T1Conflict, type T1ConflictsInput } from '@sil/t1'; -import { writeConflicts } from './pending.ts'; -import { CONFLICTS_DONE } from './preview-command.ts'; - -const input = JSON.parse(readFileSync(0, 'utf8')) as T1ConflictsInput; - -const isFilled = (value: unknown) => - typeof value === 'string' && value.trim() !== ''; -const isConflict = (conflict: T1Conflict) => - STANDARD_ID_PATTERN.test(String(conflict.id)) && - isFilled(conflict.title) && - isFilled(conflict.why) && - Object.hasOwn(T1_COPY.conflicts.kinds, conflict.kind); -// 되돌림은 빠져도 되지만, 넣었으면 버전·제목·이유·시각이 다 있어야 해요 -const isRevert = (revert: T1ConflictsInput['revert']) => - revert === undefined || - (Number.isInteger(revert.version) && - revert.version > 0 && - isFilled(revert.title) && - isFilled(revert.reason) && - !Number.isNaN(Date.parse(String(revert.createdAt)))); - -if ( - !Object.hasOwn(T1_COPY.conflicts.head, input.trigger) || - !isFilled(input.request) || - !Array.isArray(input.conflicts) || - // 다른 결정 사항이 없는 건 새 결정 사항(직접 요청)뿐이에요. 경우 1은 다른 점을 알아채서 시작해요 - (input.conflicts.length === 0 && input.trigger !== 'direct') || - !input.conflicts.every(isConflict) || - !isRevert(input.revert) -) { - console.error( - 'trigger(agent|direct), request, conflicts(각각 id·title·kind(change|mismatch)·why, 빈 목록은 direct일 때만)가 있어야 해요. revert를 넣으면 version·title·reason·createdAt이 있어야 해요', - ); - process.exit(1); -} - -// hook-conflicts.ts가 이 줄의 표로 파일을 찾아요 -console.log(`${CONFLICTS_DONE} ${writeConflicts(input)}`); diff --git a/apps/detect/src/t1/hook-conflicts.ts b/apps/detect/src/t1/hook-conflicts.ts deleted file mode 100644 index 253fffe..0000000 --- a/apps/detect/src/t1/hook-conflicts.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * hook (PostToolUse, Bash): `conflicts.ts`를 실행한 직후 다른 결정 사항 화면을 띄워요 (SIL-94 직접 요청). - * 다른 명령이면 아무것도 하지 않아요. hook 글은 도구 직후에만 화면에 보여서 이 자리에 둬요 (10/13 시험). - */ -import { readFileSync } from 'node:fs'; -import { - findConflictsToken, - isConflictsCommand, - screenHookOutput, -} from './preview-command.ts'; - -const event = JSON.parse(readFileSync(0, 'utf8')) as { - tool_input?: { command?: string }; - tool_response?: { stdout?: string }; -}; -if (!isConflictsCommand(String(event.tool_input?.command ?? ''))) { - process.exit(0); -} - -// 이 명령일 때만 패키지(@sil/core)를 불러와요 (preview-command.ts 설명) -const { takeConflicts } = await import('./pending.ts'); -const { conflictsScreen } = await import('@sil/t1'); -const input = takeConflicts( - findConflictsToken(String(event.tool_response?.stdout ?? '')), -); -if (!input) process.exit(0); - -process.stdout.write(screenHookOutput(conflictsScreen(input))); diff --git a/apps/detect/src/t1/hook-guard.ts b/apps/detect/src/t1/hook-guard.ts deleted file mode 100644 index d428e30..0000000 --- a/apps/detect/src/t1/hook-guard.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * hook (PreToolUse, save_standard): 저장하려는 값이 ③에서 사람에게 보여 준 값과 다르면 저장을 막아요. - * - ③을 보여 준 같은 결정 사항일 때만 비교해요. 임시 값이 없거나 다른 결정 사항이면 아무것도 정하지 않아요 - * ("결정 사항 새로 만들어 줘" 같은 다른 저장은 막지 않음). - * - 막지 않으면 원래대로 .claude/settings.json의 묻기 규칙이 허락 창을 띄워요. - */ -import { readFileSync } from 'node:fs'; -import type { SaveStandardInput } from '@sil/core'; -import { diffFromPending, readPending } from './pending.ts'; - -const event = JSON.parse(readFileSync(0, 'utf8')) as { - tool_input?: Partial; -}; -const input = event.tool_input ?? {}; -// 임시 값은 결정 사항마다 따로 있어서, 저장하려는 결정 사항의 것만 읽어요 -const pending = readPending(input.id); -if (!pending) process.exit(0); - -const changed = diffFromPending(pending, input); -if (changed.length > 0) { - process.stdout.write( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: `③에서 보여 준 값과 저장하려는 값이 달라요 (${changed.join(', ')}). ③을 다시 보여 주고 확인받아 주세요.`, - }, - }), - ); -} diff --git a/apps/detect/src/t1/hook-notice.ts b/apps/detect/src/t1/hook-notice.ts deleted file mode 100644 index 31967a5..0000000 --- a/apps/detect/src/t1/hook-notice.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * hook (PostToolUse, Bash): `notice.ts`를 실행한 직후 T1 예외 안내를 띄워요 (10/14 확정 예외, SIL-66). - * 다른 명령이면 아무것도 하지 않아요. hook 글은 도구 직후에만 화면에 보여서 이 자리에 둬요 (10/13 시험). - */ -import { readFileSync } from 'node:fs'; -import { - findNoticeToken, - isNoticeCommand, - screenHookOutput, -} from './preview-command.ts'; - -const event = JSON.parse(readFileSync(0, 'utf8')) as { - tool_input?: { command?: string }; - tool_response?: { stdout?: string }; -}; -if (!isNoticeCommand(String(event.tool_input?.command ?? ''))) { - process.exit(0); -} - -// 이 명령일 때만 패키지(@sil/core)를 불러와요 (preview-command.ts 설명) -const { takeNotice } = await import('./pending.ts'); -const { noticeScreen } = await import('@sil/t1'); -const input = takeNotice( - findNoticeToken(String(event.tool_response?.stdout ?? '')), -); -if (!input) process.exit(0); - -process.stdout.write(screenHookOutput(noticeScreen(input))); diff --git a/apps/detect/src/t1/hook-preview.ts b/apps/detect/src/t1/hook-preview.ts deleted file mode 100644 index 6452edb..0000000 --- a/apps/detect/src/t1/hook-preview.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * hook (PostToolUse, Bash): `preview.ts`를 실행한 직후 ③ 등록될 모습을 `screens.ts`의 모양으로 띄워요 (프로토타입 T1 ③, 10/14). - * 다른 명령이면 아무것도 하지 않아요. hook 글은 도구 직후에만 화면에 보여서 이 자리에 둬요 (10/13 시험). - */ -import { readFileSync } from 'node:fs'; -import { - findPreviewId, - isPreviewCommand, - screenHookOutput, -} from './preview-command.ts'; - -const event = JSON.parse(readFileSync(0, 'utf8')) as { - tool_input?: { command?: string }; - tool_response?: { stdout?: string }; -}; -if (!isPreviewCommand(String(event.tool_input?.command ?? ''))) { - process.exit(0); -} - -// ③ 명령일 때만 패키지(@sil/core)를 불러와요 (preview-command.ts 설명) -const { readPending } = await import('./pending.ts'); -const { previewScreen } = await import('@sil/t1'); -// preview.ts가 남긴 마지막 줄 "…들고 있어요: <결정 사항 ID>"에서 이번 ③의 결정 사항을 찾아요 -const pending = readPending( - findPreviewId(String(event.tool_response?.stdout ?? '')), -); -if (!pending) process.exit(0); - -const screen = previewScreen( - { - // 영역을 비우면 ID 앞부분이 영역이 돼요 (save_standard 설명) - area: pending.area ?? pending.id.split('.')[0] ?? '', - title: pending.title, - value: pending.value, - reason: pending.reason, - alwaysOn: pending.alwaysOn, - }, - // preview.ts가 팀 DB에서 읽어 둔 받는 이슈 (못 읽었으면 null) - pending.targets ?? null, - pending.isNew ?? false, -); - -process.stdout.write(screenHookOutput(screen)); diff --git a/apps/detect/src/t1/hook-saved.ts b/apps/detect/src/t1/hook-saved.ts deleted file mode 100644 index cb17796..0000000 --- a/apps/detect/src/t1/hook-saved.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * hook (PostToolUse·PostToolUseFailure, save_standard): 저장 직후 ④ 등록 완료를 띄우고 저장한 결정 사항의 ③ 임시 값을 지워요 (SIL-66 T1). - * - ③을 거친 저장(임시 값이 있음)이 성공했을 때만 ④를 띄워요. T1이 아닌 저장에는 아무것도 띄우지 않아요. - * - 그사이 다른 사람이 먼저 바꿔 거절됐으면(VERSION_CONFLICT) 예외 7 안내를 바로 이 자리에서 띄워요 (프로토타입 race, 10/14). - * - 다른 결정 사항의 임시 값은 건드리지 않아요 (다른 Claude Code 창이 쓰는 중일 수 있어요). - */ -import { readFileSync } from 'node:fs'; -import { clearPending, readPending } from './pending.ts'; -import { - isSavedResponse, - isVersionConflict, - SAVE_RACE_CONTEXT, - screenHookOutput, -} from './preview-command.ts'; -import { noticeScreen, savedScreen } from '@sil/t1'; - -const event = JSON.parse(readFileSync(0, 'utf8')) as { - hook_event_name?: string; - tool_input?: { id?: string }; - tool_response?: unknown; - /** PostToolUseFailure일 때 실패 내용 */ - error?: unknown; -}; -const eventName = event.hook_event_name ?? 'PostToolUse'; -const id = event.tool_input?.id; -const pending = readPending(id); -clearPending(id); - -if (pending && isSavedResponse(event.tool_response)) { - process.stdout.write( - screenHookOutput( - savedScreen( - { - // 영역을 비우면 ID 앞부분이 영역이 돼요 (save_standard 설명) - area: pending.area ?? pending.id.split('.')[0] ?? '', - title: pending.title, - alwaysOn: pending.alwaysOn, - }, - pending.targets ?? null, - pending.isNew ?? false, - ), - ), - ); -} else if (pending && isVersionConflict(event.tool_response, event.error)) { - process.stdout.write( - screenHookOutput( - noticeScreen({ kind: 'saveRace' }), - SAVE_RACE_CONTEXT, - eventName, - ), - ); -} diff --git a/apps/detect/src/t1/notice.ts b/apps/detect/src/t1/notice.ts deleted file mode 100644 index c3bc43c..0000000 --- a/apps/detect/src/t1/notice.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * T1 예외 안내 값을 임시로 들고 있는 명령이에요 (10/14 확정 예외, SIL-66). 화면은 이 명령 직후 hook(`hook-notice.ts`)이 띄워요. - * 한글이 깨지지 않게 값은 표준 입력(JSON)으로 받아요. - * node apps/detect/src/t1/notice.ts <<'EOF' - * {"kind":"searchFailed","trigger":"agent"} - * EOF - * kind: searchFailed(trigger 필요) / same·similar(existing: id·title·value 필요) / revertSame(version·versionValue와 existing 필요) / similarDeclined. - * 예외 7(저장하는 사이 바뀜)은 저장 도구 hook(`hook-saved.ts`)이 바로 띄워서 여기서 받지 않아요 - */ -import { readFileSync } from 'node:fs'; -import { STANDARD_ID_PATTERN } from '@sil/core'; -import { T1_COPY, type T1NoticeInput } from '@sil/t1'; -import { writeNotice } from './pending.ts'; -import { NOTICE_DONE } from './preview-command.ts'; - -const input = JSON.parse(readFileSync(0, 'utf8')) as T1NoticeInput; - -const isFilled = (value: unknown) => - typeof value === 'string' && value.trim() !== ''; - -function isValid(notice: T1NoticeInput): boolean { - switch (notice.kind) { - case 'searchFailed': - return Object.hasOwn( - T1_COPY.notices.searchFailed.head, - String(notice.trigger), - ); - case 'same': - case 'similar': - return ( - STANDARD_ID_PATTERN.test(String(notice.existing?.id)) && - isFilled(notice.existing.title) && - isFilled(notice.existing.value) - ); - case 'revertSame': - return ( - Number.isInteger(notice.version) && - notice.version >= 1 && - isFilled(notice.versionValue) && - STANDARD_ID_PATTERN.test(String(notice.existing?.id)) && - isFilled(notice.existing.title) && - isFilled(notice.existing.value) - ); - case 'similarDeclined': - return true; - default: - return false; - } -} - -if (!isValid(input)) { - console.error( - 'kind(searchFailed|same|similar|revertSame|similarDeclined)와 그 kind에 필요한 칸(trigger, existing의 id·title·value, revertSame이면 version·versionValue)이 있어야 해요', - ); - process.exit(1); -} - -// hook-notice.ts가 이 줄의 표로 파일을 찾아요 -console.log(`${NOTICE_DONE} ${writeNotice(input)}`); diff --git a/apps/detect/src/t1/pending.ts b/apps/detect/src/t1/pending.ts deleted file mode 100644 index 0597211..0000000 --- a/apps/detect/src/t1/pending.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * ③ 등록될 모습에 보여 준 값을 저장하기 전까지 임시로 들고 있어요 (SIL-66 T1). - * - `preview.ts`가 쓰고, hook(`hook-preview.ts`, `hook-guard.ts`, `hook-saved.ts`)이 읽고 지워요. - * - 파일은 운영체제 임시 폴더에 둬서 저장소에 남지 않아요. - * - 결정 사항마다 파일을 따로 둬요. Claude Code 창 여러 개가 다른 결정 사항을 T1으로 바꿔도 서로 덮어쓰지 않아요. - * - 시간으로 지우지 않아요. 다음 ③이 덮어쓰고, 저장하면 지워요. - */ -import { randomUUID } from 'node:crypto'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { STANDARD_ID_PATTERN, type SaveStandardInput } from '@sil/core'; -import type { T1ConflictsInput, T1NoticeInput, T1Target } from '@sil/t1'; - -/** - * 사람에게 ③으로 보여 준 값. 모양은 save_standard 입력에서 골라 써요 (타입은 core에만). - * `changedBy`는 바꾸는 사람 이름(받는 이슈에서 빼요), `targets`는 `preview.ts`가 읽어 둔 ③ 이슈 표예요(못 읽었으면 null). - * `isNew`는 기존 결정 사항이 없는 새 결정 사항일 때 true예요 (SIL-94) - */ -export type T1Pending = Pick< - SaveStandardInput, - 'id' | 'title' | 'value' | 'reason' | 'alwaysOn' | 'area' | 'changedBy' -> & { targets?: T1Target[] | null; isNew?: boolean }; - -/** 결정 사항 ID 형식이 맞을 때만 파일 경로를 줘요 (data.standard.id-format) */ -export function pendingPath(id: string): string | null { - return STANDARD_ID_PATTERN.test(id) - ? join(tmpdir(), `haribo-t1-pending-${id}.json`) - : null; -} - -export function writePending(pending: T1Pending): void { - const path = pendingPath(pending.id); - if (!path) throw new Error(`결정 사항 ID 형식이 아니에요: ${pending.id}`); - writeFileSync(path, JSON.stringify(pending, null, 2)); -} - -/** 없으면 null */ -export function readPending(id: string | undefined): T1Pending | null { - const path = id ? pendingPath(id) : null; - if (!path || !existsSync(path)) return null; - return JSON.parse(readFileSync(path, 'utf8')) as T1Pending; -} - -export function clearPending(id: string | undefined): void { - const path = id ? pendingPath(id) : null; - if (path) rmSync(path, { force: true }); -} - -/** 저장하려는 값이 ③에서 보여 준 값과 다른 칸 이름 목록. 같으면 빈 목록 */ -export function diffFromPending( - pending: T1Pending, - input: Partial, -): string[] { - const keys = ['id', 'title', 'value', 'reason', 'alwaysOn'] as const; - return keys.filter((key) => pending[key] !== input[key]); -} - -/** - * 다른 결정 사항 화면과 예외 안내 값도 같은 임시 폴더에 둬요 (SIL-94, SIL-66). - * 저장과 비교하지 않아서 결정 사항 ID 대신 명령마다 새 표(UUID)로 파일을 나누고, hook이 띄운 뒤 바로 지워요. - */ -type ShownKind = 'conflicts' | 'notice'; -const TOKEN_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - -/** 표 형식이 맞을 때만 파일 경로를 줘요 (경로 문자 막기) */ -function shownPath(kind: ShownKind, token: string): string | null { - return TOKEN_PATTERN.test(token) - ? join(tmpdir(), `haribo-t1-${kind}-${token}.json`) - : null; -} - -/** 값을 쓰고 표를 돌려줘요 */ -function writeShown(kind: ShownKind, input: unknown): string { - const token = randomUUID(); - writeFileSync( - shownPath(kind, token) as string, - JSON.stringify(input, null, 2), - ); - return token; -} - -/** 읽고 바로 지워요. 없으면 null */ -function takeShown(kind: ShownKind, token: string | undefined): T | null { - const path = token ? shownPath(kind, token) : null; - if (!path || !existsSync(path)) return null; - const input = JSON.parse(readFileSync(path, 'utf8')) as T; - rmSync(path, { force: true }); - return input; -} - -export const conflictsPath = (token: string) => shownPath('conflicts', token); -export const writeConflicts = (input: T1ConflictsInput) => - writeShown('conflicts', input); -export const takeConflicts = (token: string | undefined) => - takeShown('conflicts', token); -export const writeNotice = (input: T1NoticeInput) => - writeShown('notice', input); -export const takeNotice = (token: string | undefined) => - takeShown('notice', token); diff --git a/apps/detect/src/t1/preview-command.ts b/apps/detect/src/t1/preview-command.ts deleted file mode 100644 index b66cbc0..0000000 --- a/apps/detect/src/t1/preview-command.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * hook이 Bash 명령이 ③ 등록될 모습(`preview.ts`)이나 다른 결정 사항 화면(`conflicts.ts`)인지 알아보는 규칙이에요 (SIL-66 T1). - * 다른 Bash 명령마다 hook이 돌아서, 패키지(`@sil/core`)를 불러오지 않는 이 파일에 따로 둬요. - * 그래서 패키지를 설치하기 전에도 다른 명령에서 hook 오류가 나지 않아요. - */ - -/** preview.ts가 마지막 줄에 남기는 표시. hook-preview.ts가 이 뒤의 결정 사항 ID로 파일을 찾아요 */ -export const PREVIEW_DONE = '③ 등록될 모습 값을 임시로 들고 있어요:'; - -/** conflicts.ts가 마지막 줄에 남기는 표시. hook-conflicts.ts가 이 뒤의 표로 파일을 찾아요 */ -export const CONFLICTS_DONE = '다른 결정 사항 화면 값을 임시로 들고 있어요:'; - -/** notice.ts가 마지막 줄에 남기는 표시. hook-notice.ts가 이 뒤의 표로 파일을 찾아요 */ -export const NOTICE_DONE = 'T1 안내 값을 임시로 들고 있어요:'; - -/** - * hook이 화면을 띄운 뒤 에이전트에게만 전하는 말(additionalContext). 사람에게는 보이지 않아요. - * 에이전트는 hook 글이 사람 화면에 떴는지 몰라서 "화면을 띄웠어요" 같은 설명을 덧붙여요 (10/15 시험) - */ -export const SCREEN_SHOWN_CONTEXT = - 'T1 화면을 hook이 사람에게 이미 보여 줬어요. 화면을 띄웠다거나 무엇을 보여 줬는지 설명하는 글을 쓰지 말고, CLAUDE.md T1의 다음 단계로 바로 가세요.'; - -/** 예외 7: 저장이 거절돼 hook이 안내를 띄운 뒤 에이전트에게만 전하는 말 */ -export const SAVE_RACE_CONTEXT = - '저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿔 저장이 거절됐고, 그 안내를 hook이 사람에게 이미 보여 줬어요. 안내를 설명하지 말고 CLAUDE.md T1 9의 "저장하는 사이 바뀜"대로 1부터 다시 하세요.'; - -/** - * hook 출력: 사람에게 보이는 화면과 에이전트에게만 가는 말. - * eventName은 hook이 불린 때예요. 저장이 실패하면 Claude Code가 PostToolUseFailure로 부를 수 있어요 - */ -export function screenHookOutput( - screen: string, - context: string = SCREEN_SHOWN_CONTEXT, - eventName = 'PostToolUse', -): string { - return JSON.stringify({ - // 첫 줄을 비워 hook 머리글(`PostToolUse:Bash says:`) 바로 다음 줄부터 시작해요 (프로토타입 screens.py) - systemMessage: `\n${screen}`, - hookSpecificOutput: { - hookEventName: eventName, - additionalContext: context, - }, - }); -} - -/** save_standard가 그사이 바뀐 버전 때문에 거절됐는지 봐요 (서버 오류 코드 VERSION_CONFLICT, SIL-15 동시 수정) */ -export function isVersionConflict(...responses: unknown[]): boolean { - return responses.some((response) => - (typeof response === 'string' - ? response - : (JSON.stringify(response) ?? '') - ).includes('VERSION_CONFLICT'), - ); -} - -// 앞에 `cd "…" &&`를 붙이거나 전체 경로로 불러도 알아봐요 (10/14 main-2에서 cd가 붙어 ③이 안 뜸) -const t1Command = (file: string) => - new RegExp( - String.raw`(?:^|[;&|(]\s*)node\s+(?:"[^"\n]*[\\/]|'[^'\n]*[\\/]|[^\s"';&|]*[\\/])?apps[\\/]detect[\\/]src[\\/]t1[\\/]${file}\.ts\b`, - 'm', - ); -const PREVIEW_COMMAND = t1Command('preview'); -const CONFLICTS_COMMAND = t1Command('conflicts'); -const NOTICE_COMMAND = t1Command('notice'); - -export function isPreviewCommand(command: string): boolean { - return PREVIEW_COMMAND.test(command); -} - -export function isConflictsCommand(command: string): boolean { - return CONFLICTS_COMMAND.test(command); -} - -/** - * save_standard 결과가 저장에 성공했는지 봐요. Claude Code가 MCP 결과를 어떤 겉모양(배열·content·글)으로 - * 넘겨도 찾게, 안쪽 글이 JSON이면 풀어서 standardId와 version이 있는 결과를 찾아요. 오류(isError)면 false - */ -export function isSavedResponse(response: unknown): boolean { - if (typeof response === 'string') { - try { - return isSavedResponse(JSON.parse(response)); - } catch (_error) { - return false; - } - } - if (Array.isArray(response)) return response.some(isSavedResponse); - if (typeof response !== 'object' || response === null) return false; - const record = response as Record; - if (record.isError === true) return false; - if ( - typeof record.standardId === 'string' && - typeof record.version === 'number' - ) { - return true; - } - return Object.values(record).some(isSavedResponse); -} - -/** 명령 출력에서 표시 줄 뒤의 값을 찾아요. 없으면 undefined */ -function findAfter(stdout: string, marker: string): string | undefined { - const doneLine = stdout.split('\n').find((line) => line.startsWith(marker)); - return doneLine?.slice(marker.length).trim() || undefined; -} - -/** preview.ts 출력에서 이번 ③의 결정 사항 ID를 찾아요. 없으면 undefined */ -export function findPreviewId(stdout: string): string | undefined { - return findAfter(stdout, PREVIEW_DONE); -} - -/** conflicts.ts 출력에서 이번 화면의 표를 찾아요. 없으면 undefined */ -export function findConflictsToken(stdout: string): string | undefined { - return findAfter(stdout, CONFLICTS_DONE); -} - -export function isNoticeCommand(command: string): boolean { - return NOTICE_COMMAND.test(command); -} - -/** notice.ts 출력에서 이번 안내의 표를 찾아요. 없으면 undefined */ -export function findNoticeToken(stdout: string): string | undefined { - return findAfter(stdout, NOTICE_DONE); -} diff --git a/apps/detect/src/t1/preview.ts b/apps/detect/src/t1/preview.ts deleted file mode 100644 index 5919050..0000000 --- a/apps/detect/src/t1/preview.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * T1 5단계: ③ 등록될 모습 값을 임시로 들고 있는 명령이에요. 화면은 이 명령 직후 hook(`hook-preview.ts`)이 띄워요. - * 한글이 깨지지 않게 값은 표준 입력(JSON)으로 받아요. - * node apps/detect/src/t1/preview.ts <<'EOF' - * {"id":"app.notify.title-length","title":"…","value":"…","reason":"…","alwaysOn":false,"changedBy":"경하"} - * EOF - * 기존 결정 사항이 없는 새 결정 사항이면 `"isNew":true`를 더해요 (SIL-94). - * ③ 이슈 표에 넣을 받는 이슈도 여기서 팀 DB로 읽어 함께 들고 있어요 (SIL-94). 에이전트는 출력의 - * "알림 받는 이슈" 줄을 보고 등록하기 설명을 골라요. - */ -import { readFileSync } from 'node:fs'; -import { loadTargets } from '@sil/t1'; -import { pendingPath, type T1Pending, writePending } from './pending.ts'; -import { PREVIEW_DONE } from './preview-command.ts'; - -/** 에이전트가 읽는 받는 이슈 수 줄. 1개 이상이면 `registerWithIssues`, 아니면 `register`를 골라요 (CLAUDE.md T1 5) */ -const TARGETS_LINE = '알림 받는 이슈:'; - -const pending = JSON.parse(readFileSync(0, 'utf8')) as T1Pending; - -const missing = (['id', 'title', 'value', 'reason'] as const).filter( - (key) => typeof pending[key] !== 'string' || pending[key].trim() === '', -); -if (missing.length > 0 || typeof pending.alwaysOn !== 'boolean') { - console.error( - `빠진 칸이 있어요: ${[...missing, ...(typeof pending.alwaysOn === 'boolean' ? [] : ['alwaysOn'])].join(', ')}`, - ); - process.exit(1); -} - -if (pending.isNew !== undefined && typeof pending.isNew !== 'boolean') { - console.error('isNew는 true나 false여야 해요'); - process.exit(1); -} - -if (!pendingPath(pending.id)) { - console.error(`결정 사항 ID 형식이 아니에요: ${pending.id}`); - process.exit(1); -} - -// 새 결정 사항에는 아직 연결된 이슈가 없어요 (프로토타입 10/14 "알림 받을 사람이 없어요") -const targets = pending.isNew - ? [] - : await loadTargets(pending.id, pending.changedBy); -writePending({ ...pending, targets }); -console.log( - targets - ? `${TARGETS_LINE} ${targets.length}개` - : `${TARGETS_LINE} 알 수 없어요 (팀 DB를 읽지 못했어요)`, -); -// hook-preview.ts가 이 줄의 결정 사항 ID로 파일을 찾아요 -console.log(`${PREVIEW_DONE} ${pending.id}`); diff --git a/apps/detect/src/t1/t1.test.ts b/apps/detect/src/t1/t1.test.ts deleted file mode 100644 index 0293013..0000000 --- a/apps/detect/src/t1/t1.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import type { T1ConflictsInput } from '@sil/t1'; -import { describe, expect, it } from 'vitest'; -import { - clearPending, - conflictsPath, - diffFromPending, - pendingPath, - takeConflicts, - takeNotice, - writeConflicts, - writePending, -} from './pending.ts'; -import { - CONFLICTS_DONE, - findConflictsToken, - findNoticeToken, - findPreviewId, - isConflictsCommand, - isNoticeCommand, - isPreviewCommand, - isSavedResponse, - isVersionConflict, - NOTICE_DONE, - PREVIEW_DONE, - SAVE_RACE_CONTEXT, - SCREEN_SHOWN_CONTEXT, -} from './preview-command.ts'; - -/** 꾸밈 코드(ANSI)를 지워요 */ -// eslint-disable-next-line no-control-regex -- 터미널 색 코드(ESC)를 지우려면 제어 문자가 필요해요 -const stripAnsi = (text: string) => text.replace(/\u001b\[[0-9;]*m/g, ''); - -/** 프로토타입 경우 2의 다른 결정 사항 (10/14) */ -const TITLE_CONFLICTS: T1ConflictsInput = { - trigger: 'direct', - request: '앱 알림 제목 길이를 32자 이하로 바꾸기', - conflicts: [ - { - id: 'app.notify.title-length', - title: '앱 알림 제목은 40자 이하로 쓴다', - kind: 'change', - why: '40자 이하로 정한 길이를 32자 이하로 다시 정해요', - }, - { - id: 'notify.app-title.from-standard', - title: '앱 알림 제목에는 결정 사항 제목을 그대로 쓴다', - kind: 'mismatch', - why: '결정 사항 제목(최대 40자)을 그대로 쓰면 32자에서 잘려요', - }, - ], -}; - -describe('diffFromPending', () => { - const pending = { - id: 'app.notify.title-length', - title: '앱 알림 제목은 32자 이하다', - value: '운영체제 알림 창에 뜨는 제목은 공백 포함 32자 이하로 쓴다', - reason: 'Windows 알림 창에서 32자를 넘으면 제목 끝이 잘린다', - alwaysOn: false, - }; - - it('③에서 보여 준 값과 같으면 빈 목록이에요', () => { - expect(diffFromPending(pending, { ...pending, baseVersion: 1 })).toEqual( - [], - ); - }); - - it('바뀐 칸 이름을 알려 줘요', () => { - expect( - diffFromPending(pending, { - ...pending, - reason: '다른 이유', - alwaysOn: true, - }), - ).toEqual(['reason', 'alwaysOn']); - }); -}); - -describe('pendingPath', () => { - it('결정 사항마다 파일을 따로 둬요', () => { - expect(pendingPath('app.notify.title-length')).toMatch( - /haribo-t1-pending-app\.notify\.title-length\.json$/, - ); - }); - - it('결정 사항 ID 형식이 아니면 파일을 만들지 않아요 (경로 문자 막기)', () => { - expect(pendingPath('../../etc.passwd.x')).toBeNull(); - expect(pendingPath('App.Notify.Title')).toBeNull(); - }); -}); - -describe('isPreviewCommand', () => { - it('node로 시작하는 ③ 명령을 알아봐요', () => { - expect( - isPreviewCommand("node apps/detect/src/t1/preview.ts <<'EOF'\n{}\nEOF"), - ).toBe(true); - expect(isPreviewCommand('node ./apps/detect/src/t1/preview.ts')).toBe(true); - }); - - it('앞에 cd를 붙이거나 전체 경로로 불러도 알아봐요', () => { - expect( - isPreviewCommand( - `cd "C:/Users/user/orca/workspaces/HARIBO/main-2" && node apps/detect/src/t1/preview.ts <<'EOF'`, - ), - ).toBe(true); - expect( - isPreviewCommand('cd /repo; node /repo/apps/detect/src/t1/preview.ts'), - ).toBe(true); - expect( - isPreviewCommand( - 'node "C:/Users/my name/HARIBO/apps/detect/src/t1/preview.ts"', - ), - ).toBe(true); - expect( - isPreviewCommand('node C:\\repo\\apps\\detect\\src\\t1\\preview.ts'), - ).toBe(true); - }); - - it('다른 명령은 알아보지 않아요', () => { - expect(isPreviewCommand('cat apps/detect/src/t1/preview.ts')).toBe(false); - expect(isPreviewCommand('node apps/detect/src/t1/preview.test.ts')).toBe( - false, - ); - expect(isPreviewCommand('node apps/detect/src/t1/hook-preview.ts')).toBe( - false, - ); - expect(isPreviewCommand('pnpm test')).toBe(false); - }); -}); - -describe('findPreviewId', () => { - it('preview.ts 마지막 줄에서 결정 사항 ID를 찾아요 (Windows 줄바꿈 포함)', () => { - expect(findPreviewId(`${PREVIEW_DONE} app.notify.title-length\r\n`)).toBe( - 'app.notify.title-length', - ); - }); - - it('표시 줄이 없으면 undefined예요', () => { - expect(findPreviewId('빠진 칸이 있어요: reason')).toBeUndefined(); - }); -}); - -describe('hook-preview.ts', () => { - const HOOK_PATH = fileURLToPath( - new URL('./hook-preview.ts', import.meta.url), - ); - const runHook = (command: string, stdout: string) => - execFileSync(process.execPath, [HOOK_PATH], { - input: JSON.stringify({ - tool_input: { command }, - tool_response: { stdout }, - }), - encoding: 'utf8', - }); - - it('앞에 cd가 붙은 ③ 명령에도 꾸민 ③을 띄워요', () => { - const id = 'test.t1.hook-preview'; - writePending({ - id, - title: '시험 제목', - value: '시험 값', - reason: '시험 이유', - alwaysOn: false, - }); - try { - const output = runHook( - `cd "C:/repo/main-2" && node apps/detect/src/t1/preview.ts <<'EOF'`, - `${PREVIEW_DONE} ${id}\n`, - ); - const { systemMessage } = JSON.parse(output) as { systemMessage: string }; - expect(systemMessage).toContain('\u001b[0;1;36m[test] 시험 제목'); - expect(stripAnsi(systemMessage)).toContain(' 값 시험 값'); - } finally { - clearPending(id); - } - }); - - it('다른 명령이면 아무것도 내지 않아요', () => { - expect(runHook('git status', '')).toBe(''); - }); -}); - -describe('conflicts.ts와 hook-conflicts.ts (SIL-94)', () => { - const runNode = (file: string, input: string) => - execFileSync( - process.execPath, - [fileURLToPath(new URL(file, import.meta.url))], - { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, - ); - const runHook = (command: string, stdout: string) => - runNode( - './hook-conflicts.ts', - JSON.stringify({ tool_input: { command }, tool_response: { stdout } }), - ); - - it('명령이 값을 들고, 직후 hook이 화면을 띄우고 임시 값을 지워요', () => { - const stdout = runNode('./conflicts.ts', JSON.stringify(TITLE_CONFLICTS)); - const token = findConflictsToken(stdout); - expect(token).toBeDefined(); - const output = runHook( - `cd "C:/repo/main-2" && node apps/detect/src/t1/conflicts.ts <<'EOF'`, - stdout, - ); - const { systemMessage } = JSON.parse(output) as { systemMessage: string }; - expect(stripAnsi(systemMessage)).toContain( - ' 어긋남 [notify] 앱 알림 제목에는 결정 사항 제목을 그대로 쓴다', - ); - expect(takeConflicts(token)).toBeNull(); - }); - - it('다른 결정 사항이 없는 화면은 직접 요청(새 결정 사항)에서만 받아요', () => { - const stdout = runNode( - './conflicts.ts', - JSON.stringify({ ...TITLE_CONFLICTS, conflicts: [] }), - ); - expect(takeConflicts(findConflictsToken(stdout))).toMatchObject({ - conflicts: [], - }); - }); - - it('trigger·구분이 정해진 값이 아니거나, 경우 1인데 다른 결정 사항이 없으면 거절해요', () => { - const [first] = TITLE_CONFLICTS.conflicts; - expect(() => - runNode( - './conflicts.ts', - JSON.stringify({ ...TITLE_CONFLICTS, trigger: 'x' }), - ), - ).toThrow(); - expect(() => - runNode( - './conflicts.ts', - JSON.stringify({ - ...TITLE_CONFLICTS, - trigger: 'agent', - conflicts: [], - }), - ), - ).toThrow(); - expect(() => - runNode( - './conflicts.ts', - JSON.stringify({ - ...TITLE_CONFLICTS, - conflicts: [{ ...first, kind: 'x' }], - }), - ), - ).toThrow(); - }); - - it('다른 명령이면 아무것도 내지 않아요', () => { - expect(runHook('git status', '')).toBe(''); - expect(runHook('node apps/detect/src/t1/preview.ts', '')).toBe(''); - }); - - it('표 형식이 아니면 파일을 찾지 않아요 (경로 문자 막기)', () => { - expect(conflictsPath('../../etc')).toBeNull(); - const token = writeConflicts(TITLE_CONFLICTS); - expect(conflictsPath(token)).toMatch(/haribo-t1-conflicts-/); - expect(takeConflicts(token)).toEqual(TITLE_CONFLICTS); - }); - - it('명령 알아보기와 표 찾기', () => { - expect(isConflictsCommand('node apps/detect/src/t1/conflicts.ts')).toBe( - true, - ); - expect(isConflictsCommand('cat apps/detect/src/t1/conflicts.ts')).toBe( - false, - ); - expect(isConflictsCommand('node apps/detect/src/t1/preview.ts')).toBe( - false, - ); - expect(findConflictsToken(`${CONFLICTS_DONE} abc\r\n`)).toBe('abc'); - }); -}); - -describe('hooks 첫 줄', () => { - it('두 hook 모두 첫 줄을 비워 머리글 다음 줄부터 시작해요', () => { - const id = 'test.t1.first-line'; - writePending({ - id, - title: '제목', - value: '값', - reason: '이유', - alwaysOn: false, - }); - const run = (file: string, command: string, stdout: string) => { - const output = JSON.parse( - execFileSync( - process.execPath, - [fileURLToPath(new URL(file, import.meta.url))], - { - input: JSON.stringify({ - tool_input: { command }, - tool_response: { stdout }, - }), - encoding: 'utf8', - }, - ), - ) as { - systemMessage: string; - hookSpecificOutput: { additionalContext: string }; - }; - // 에이전트에게는 화면을 이미 보여 줬으니 설명하지 말라고만 전해요 (10/15) - expect(output.hookSpecificOutput.additionalContext).toBe( - SCREEN_SHOWN_CONTEXT, - ); - return output.systemMessage; - }; - try { - const preview = run( - './hook-preview.ts', - 'node apps/detect/src/t1/preview.ts', - `${PREVIEW_DONE} ${id}`, - ); - expect(preview.startsWith('\n-- 결정 사항을 이렇게')).toBe(true); - const conflicts = run( - './hook-conflicts.ts', - 'node apps/detect/src/t1/conflicts.ts', - `${CONFLICTS_DONE} ${writeConflicts(TITLE_CONFLICTS)}`, - ); - expect(conflicts.startsWith('\n\u001b[0;7;33m')).toBe(true); - } finally { - clearPending(id); - } - }); -}); - -describe('isSavedResponse', () => { - const result = { standardId: 'app.notify.title-length', version: 2 }; - - it('MCP 결과가 어떤 겉모양이든 저장 결과를 찾아요', () => { - const text = JSON.stringify(result); - expect(isSavedResponse({ content: [{ type: 'text', text }] })).toBe(true); - expect(isSavedResponse([{ type: 'text', text }])).toBe(true); - expect(isSavedResponse(text)).toBe(true); - }); - - it('오류이거나 저장 결과가 없으면 false예요', () => { - expect( - isSavedResponse({ - isError: true, - content: [{ type: 'text', text: '[CONFLICT] 그사이 바뀌었어요' }], - }), - ).toBe(false); - expect(isSavedResponse([{ type: 'text', text: '저장하지 못했어요' }])).toBe( - false, - ); - expect(isSavedResponse(undefined)).toBe(false); - }); -}); - -describe('isVersionConflict', () => { - it('저장 결과나 실패 내용에 VERSION_CONFLICT가 있으면 true예요', () => { - expect(isVersionConflict(undefined, '[VERSION_CONFLICT] 바뀌었어요')).toBe( - true, - ); - expect( - isVersionConflict({ content: [{ text: '[VERSION_CONFLICT] …' }] }), - ).toBe(true); - expect(isVersionConflict({ content: [{ text: '[FORBIDDEN] …' }] })).toBe( - false, - ); - }); -}); - -describe('hook-saved.ts', () => { - const runHook = (id: string, response: unknown) => - execFileSync( - process.execPath, - [fileURLToPath(new URL('./hook-saved.ts', import.meta.url))], - { - input: JSON.stringify({ - tool_input: { id }, - tool_response: response, - }), - encoding: 'utf8', - }, - ); - const saved = (id: string) => [ - { type: 'text', text: JSON.stringify({ standardId: id, version: 2 }) }, - ]; - - it('③을 거친 저장이 성공하면 ④를 띄우고 임시 값을 지워요', () => { - const id = 'test.t1.hook-saved'; - writePending({ - id, - title: '시험 제목', - value: '시험 값', - reason: '시험 이유', - alwaysOn: false, - targets: [], - }); - const output = JSON.parse(runHook(id, saved(id))) as { - systemMessage: string; - hookSpecificOutput: { additionalContext: string }; - }; - expect(stripAnsi(output.systemMessage)).toContain( - '✓ 결정 사항을 등록했어요.', - ); - expect(output.hookSpecificOutput.additionalContext).toBe( - SCREEN_SHOWN_CONTEXT, - ); - expect(existsSync(pendingPath(id) ?? '')).toBe(false); - }); - - it('저장이 실패하면 ④를 띄우지 않아요', () => { - const id = 'test.t1.hook-saved-fail'; - writePending({ - id, - title: '시험 제목', - value: '시험 값', - reason: '시험 이유', - alwaysOn: false, - }); - expect(runHook(id, { isError: true, content: [] })).toBe(''); - clearPending(id); - }); - - it('그사이 바뀌어 거절되면(VERSION_CONFLICT) 그 자리에서 예외 7 안내를 띄워요 (PostToolUse·PostToolUseFailure)', () => { - const id = 'test.t1.hook-saved-race'; - const pending = { - id, - title: '시험 제목', - value: '시험 값', - reason: '시험 이유', - alwaysOn: false, - }; - const rejected = { - isError: true, - content: [ - { type: 'text', text: '[VERSION_CONFLICT] 그사이 v2로 바뀌었어요' }, - ], - }; - for (const [eventName, extra] of [ - ['PostToolUse', { tool_response: rejected }], - [ - 'PostToolUseFailure', - { error: '[VERSION_CONFLICT] 그사이 v2로 바뀌었어요' }, - ], - ] as const) { - writePending(pending); - const output = JSON.parse( - execFileSync( - process.execPath, - [fileURLToPath(new URL('./hook-saved.ts', import.meta.url))], - { - input: JSON.stringify({ - hook_event_name: eventName, - tool_input: { id }, - ...extra, - }), - encoding: 'utf8', - }, - ), - ) as { - systemMessage: string; - hookSpecificOutput: { - hookEventName: string; - additionalContext: string; - }; - }; - expect(stripAnsi(output.systemMessage).split('\n')).toEqual([ - '', - ' 저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿨어요.', - ' 바뀐 내용을 다시 확인할게요.', - ]); - expect(output.hookSpecificOutput).toEqual({ - hookEventName: eventName, - additionalContext: SAVE_RACE_CONTEXT, - }); - } - }); - - it('③을 거치지 않은 저장에는 아무것도 띄우지 않아요', () => { - expect(runHook('test.t1.no-pending', saved('test.t1.no-pending'))).toBe(''); - }); -}); - -describe('notice.ts와 hook-notice.ts', () => { - const runNode = (file: string, input: string) => - execFileSync( - process.execPath, - [fileURLToPath(new URL(file, import.meta.url))], - { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }, - ); - - it('명령이 값을 들고, 직후 hook이 안내를 띄우고 임시 값을 지워요', () => { - const stdout = runNode( - './notice.ts', - JSON.stringify({ kind: 'similarDeclined' }), - ); - const token = findNoticeToken(stdout); - const output = JSON.parse( - runNode( - './hook-notice.ts', - JSON.stringify({ - tool_input: { command: 'node apps/detect/src/t1/notice.ts' }, - tool_response: { stdout }, - }), - ), - ) as { systemMessage: string }; - expect(stripAnsi(output.systemMessage)).toContain( - '등록하지 않았어요. 기존 결정 사항을 그대로 둘게요.', - ); - expect(takeNotice(token)).toBeNull(); - }); - - it('kind나 필요한 칸이 없으면 거절해요', () => { - for (const bad of [ - { kind: 'x' }, - // 예외 7은 저장 도구 hook이 띄워요 - { kind: 'saveRace' }, - { kind: 'searchFailed' }, - { kind: 'same', existing: { id: 'app.notify.title-length' } }, - // 되돌림이 같음은 몇 버전인지와 그 버전 값이 있어야 해요 - { - kind: 'revertSame', - versionValue: '값', - existing: { id: 'app.notify.title-length', title: '제목', value: '값' }, - }, - { - kind: 'revertSame', - version: 2, - existing: { id: 'app.notify.title-length', title: '제목', value: '값' }, - }, - ]) { - expect(() => runNode('./notice.ts', JSON.stringify(bad))).toThrow(); - } - }); - - it('다른 명령은 알아보지 않아요', () => { - expect(isNoticeCommand('node apps/detect/src/t1/notice.ts')).toBe(true); - expect(isNoticeCommand('node apps/detect/src/t1/conflicts.ts')).toBe(false); - expect(findNoticeToken(`${NOTICE_DONE} abc\r\n`)).toBe('abc'); - }); -}); diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index a17cca5..4316b66 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -39,18 +39,18 @@ mcp-server/ ## 도구 -| 도구 | 하는 일 | 읽기만 | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| `ping` | 서버·DB 연결, 누구로 들어왔는지 확인 | ✔ | -| `get_index` | 목차: 항상 적용 기준, 기준 전체 목록(ID·영역·제목), 영역별 수 | ✔ | -| `search_standards` | 키워드, 영역, 코드 경로(glob)로 기준 찾기 | ✔ | -| `get_standard` | 기준 하나의 최신 내용과 버전 기록 (`currentVersion`) | ✔ | -| `save_standard` | 새 기준·새 버전 저장. ID·제목·값·이유·항상 적용이 없으면 빠진 칸을 알려 주고 거절 | | -| `revert` | 앞선 버전으로 되돌리기. 되돌림도 새 버전으로 쌓임 | | -| `list_pending` | 나(`X-Sil-User`)에게 온 확인할 변경 목록. 제목·이전 값·지금 값·이유·이슈와, 대기 항목에는 물을 질문(`questions`)을 붙여 줌 | ✔ | -| `respond` | 확인할 변경에 답 저장 (관계, 알았나, 관계없는 이유). 답한 곳은 터미널 | | -| `mark_reflected` | 관계있다로 답한 변경을 내 작업에 반영했다고 표시하거나 풀기. 사람이 직접 말했을 때만 | | -| `show_standard_check` | T1 결정 사항 확인 화면(`screen`: `conflicts` ② 다른 결정 사항 / `preview` ③ 등록될 모습 / `notice` 예외 안내). 입력만 검사하고 `{ ok: true }`. 화면은 hook이 띄우고 저장하지 않아요. 준비 중이라 아직 부르지 않아요(BDT-108 PR B에서 설명을 채워요) | ✔ | +| 도구 | 하는 일 | 읽기만 | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| `ping` | 서버·DB 연결, 누구로 들어왔는지 확인 | ✔ | +| `get_index` | 목차: 항상 적용 기준, 기준 전체 목록(ID·영역·제목), 영역별 수 | ✔ | +| `search_standards` | 키워드, 영역, 코드 경로(glob)로 기준 찾기 | ✔ | +| `get_standard` | 기준 하나의 최신 내용과 버전 기록 (`currentVersion`) | ✔ | +| `save_standard` | 새 기준·새 버전 저장. ID·제목·값·이유·항상 적용이 없으면 빠진 칸을 알려 주고 거절 | | +| `revert` | 앞선 버전으로 되돌리기. 되돌림도 새 버전으로 쌓임 | | +| `list_pending` | 나(`X-Sil-User`)에게 온 확인할 변경 목록. 제목·이전 값·지금 값·이유·이슈와, 대기 항목에는 물을 질문(`questions`)을 붙여 줌 | ✔ | +| `respond` | 확인할 변경에 답 저장 (관계, 알았나, 관계없는 이유). 답한 곳은 터미널 | | +| `mark_reflected` | 관계있다로 답한 변경을 내 작업에 반영했다고 표시하거나 풀기. 사람이 직접 말했을 때만 | | +| `show_standard_check` | T1 결정 사항 확인 화면(`screen`: `conflicts` ② 다른 결정 사항 / `preview` ③ 등록될 모습 / `notice` 예외 안내). 입력만 검사하고 `{ ok: true }`. 화면은 hook이 띄우고 저장하지 않아요. 언제 부르는지와 넘길 값은 도구 설명, 그다음 단계는 hook이 알려 줘요 | ✔ | 알리기 도구는 `X-Sil-User`의 이름으로만 움직여요. 다른 사람의 목록을 보거나 대신 답할 수 없고, 이름이 없으면 거절해요. `respond`는 DB까지 가기 전에 `@sil/notify`의 `checkAnswer`로 먼저 검사해서 빠진 칸을 한국어로 알려 줘요. 이미 닫힌 변경이면 `closedStatus`(답함·대체됨·자동 닫힘)를 돌려줘요. "관계있다"로 저장되면 `scopeCheck`(그 버전의 적용 범위와 안내)도 돌려줘요. AI는 적용 범위부터 훑어 새 값과 다른 곳을 표(위치 | 코드 | 지금 값)로 알려 주기만 하고 고치지 않아요. 다른 곳이 없으면 없다고 말하고, 적용 범위가 없거나 경로를 하나도 못 찾으면 그렇다고 말한 뒤 사람이 원할 때만 제목과 값으로 찾아요 (SIL-98). "잘 모르겠다"는 `unsure: true`로 불러서 DB에 쓰지 않고 대기로 남겨요 (notify.answer.unsure-later). "관계있다"로 저장되면 `rereadStandardId`를 돌려줘서 AI가 그 결정 사항을 `get_standard`로 다시 읽어요. @@ -103,8 +103,9 @@ T1(결정 사항과 다르면 먼저 묻기)을 이 저장소 밖에서도 띄 | `PreToolUse` (`mcp__haribo__save_standard`) | `POST /hooks/before-save` | ③에서 보여 준 값과 다르면 막고(`deny`), 같으면 사람에게 허락을 물어요(`ask`) | | `PostToolUse`·`PostToolUseFailure` (`mcp__haribo__save_standard`) | `POST /hooks/after-save` | ④ 등록 완료. 그사이 다른 사람이 먼저 바꿨으면(`VERSION_CONFLICT`) 예외 7 | -- **③ 값은 팀원 PC에 둬요:** 서버는 쉬었다 깨어나면 메모리가 비어서 ③ 값을 기억하지 않아요. `hook.mjs`가 ③ 직후 `~/.claude/haribo/t1-<세션>-<결정 사항 ID>.json`을 남기고, 저장 직전·직후에 그 값을 `t1_preview`로 붙여 보내요. 저장이 끝나면 지워요. -- **③을 거치지 않은 저장은 그대로 둬요(PR A):** ③ 파일이 없으면 저장 직전·직후 hooks는 아무것도 하지 않아요. 그래서 이 저장소의 지금 T1(CLAUDE.md + Bash hooks)과 ④가 두 번 뜨지 않고, 안내문 2·3번 저장도 막히지 않아요. 막기는 안내문을 바꾸는 PR B에서 켜요. +- **③ 값은 팀원 PC에 둬요:** 서버는 쉬었다 깨어나면 메모리가 비어서 ③ 값을 기억하지 않아요. `hook.mjs`가 ③ 직후 `~/.claude/haribo/t1-<세션>-<결정 사항 ID>.json`을 남기고, 저장 직전·직후에 그 값을 `t1_preview`로 붙여 보내요. 저장이 끝나면 지워요. ③에서 취소·Esc로 끝나 남은 파일은 새 세션을 켤 때 하루 넘은 것부터 지워요. +- **③을 거치지 않은 저장은 막아요:** ③ 파일이 없으면 저장 직전 hook이 막고 `show_standard_check`로 ③부터 하라고 알려요. 안내문 2·3·10번도 저장 전에 ③을 보여 주게 했어요. ③과 다른 ID로 저장할 때도 파일이 없어 막혀요. 서버에 못 붙으면 막지 않아요. +- **지시는 세 곳에 나눠 둬요:** 언제 부르는지는 안내문 10번(늘 보여요), 부르기 전에 할 일과 넘길 값은 도구 설명(미뤄 불러와요, 16,384자까지), 부른 뒤 할 일과 물을 질문은 hook이 그때그때 넘겨요. 예전에는 이 저장소 `CLAUDE.md` T1 1~9번에 있었어요. - **실패:** 화면 직후 서버에 못 붙으면 Claude에게 "다음 단계로 가지 말고 멈춰요"를 알려요. ③을 거친 저장 직전에 못 붙으면 그래도 사람에게 물어요. 저장 직후 실패는 조용히 끝나요. - **다시 설치:** 이 hooks는 `pnpm mcp:register --user <이름>`을 다시 실행해야 깔려요. diff --git a/apps/mcp-server/hooks-client/haribo-hook.mjs b/apps/mcp-server/hooks-client/haribo-hook.mjs index ebbb637..0b80088 100644 --- a/apps/mcp-server/hooks-client/haribo-hook.mjs +++ b/apps/mcp-server/hooks-client/haribo-hook.mjs @@ -15,8 +15,15 @@ * - T1: ③ 등록될 모습 직후(after-t1) 보여 준 값을 같은 폴더에 "세션 번호 + 결정 사항 ID" 파일로 남기고, * 저장 직전·직후(before-save, after-save)에 그 값을 t1_preview로 붙여 보내요. 저장이 끝나면 지워요. * 서버는 쉬었다 깨어나면 메모리가 비어서 ③ 값을 내 PC에 둬요 (BDT-108 10/23). + * ③에서 취소하면 파일이 남아서, 새 세션을 켤 때(session-start) 하루 넘은 파일을 지워요. */ -import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; const EVENTS = [ 'session-start', @@ -52,6 +59,24 @@ function previewFile(sessionId, id) { return new URL(`./t1-${sessionId}-${id}.json`, import.meta.url); } +/** 남은 ③ 파일을 지우는 나이. 같은 날 다른 창에서 쓰는 중인 파일은 건드리지 않아요 */ +const PREVIEW_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** ③에서 취소·Esc로 끝나 남은 파일 중 하루 넘은 것을 지워요. 못 지우면 그냥 넘어가요 */ +function removeOldPreviews() { + const dir = new URL('./', import.meta.url); + try { + for (const name of readdirSync(dir)) { + if (!/^t1-.+\.json$/.test(name)) continue; + const file = new URL(name, dir); + if (Date.now() - statSync(file).mtimeMs > PREVIEW_MAX_AGE_MS) + rmSync(file, { force: true }); + } + } catch { + // 정리는 다음 세션에 다시 해요 + } +} + /** 이번 저장에 붙인 ③ 값. 없으면 ③을 거치지 않은 저장이에요 */ let t1Preview; @@ -83,7 +108,7 @@ function keepPreview(input, body) { if (parsed.tool_input?.screen === 'preview' && file) writeFileSync(file, JSON.stringify(parsed.tool_input.preview), 'utf8'); } catch { - // 남기지 못하면 저장 직전 확인을 건너뛰어요 (PR A에서는 막지 않음) + // 남기지 못하면 저장 직전에 ③이 없다고 막혀서 ③을 다시 보여 줘요 } } @@ -145,6 +170,7 @@ async function readStdin() { async function main() { if (!EVENTS.includes(event)) return ''; + if (event === 'session-start') removeOldPreviews(); const input = await readStdin(); let config; try { diff --git a/apps/mcp-server/src/hooks/install.test.ts b/apps/mcp-server/src/hooks/install.test.ts index d552f5e..b03409a 100644 --- a/apps/mcp-server/src/hooks/install.test.ts +++ b/apps/mcp-server/src/hooks/install.test.ts @@ -6,6 +6,7 @@ import { readdirSync, readFileSync, rmSync, + utimesSync, writeFileSync, existsSync, } from 'node:fs'; @@ -145,14 +146,13 @@ describe('mergeHooks: T1 결정 사항 확인 (BDT-108)', () => { ]); }); - it('이 저장소 .claude/settings.json의 지금 T1 hooks(Bash)는 지우지 않아요', () => { + it('같은 도구에 건 다른 hooks는 지우지 않아요', () => { const bash = { matcher: 'mcp__haribo__save_standard', hooks: [ { type: 'command' as const, - command: - 'node "$CLAUDE_PROJECT_DIR/apps/detect/src/t1/hook-guard.ts"', + command: 'node "$CLAUDE_PROJECT_DIR/scripts/my-guard.ts"', }, ], }; @@ -391,6 +391,40 @@ describe('팀원 PC 스크립트: T1 ③ 값 파일 (BDT-108)', () => { } }); + it('새 세션을 켤 때 하루 넘은 ③ 파일만 지워요 (취소·Esc로 남은 파일)', async () => { + const server = await fakeServer(); + try { + const home = tempHome(); + const { scriptPath, configPath } = installHooks({ + home, + hooksUrl: server.hooksUrl, + user: '경하', + scriptSource: SCRIPT, + }); + const dir = join(home, '.claude', 'haribo'); + const oldFile = join(dir, `t1-old-${preview.id}.json`); + const newFile = join(dir, `t1-new-${preview.id}.json`); + writeFileSync(oldFile, JSON.stringify(preview)); + writeFileSync(newFile, JSON.stringify(preview)); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + utimesSync(oldFile, twoDaysAgo, twoDaysAgo); + utimesSync(configPath, twoDaysAgo, twoDaysAgo); + + // 정리는 세션을 켤 때만 해요 + await runHook(scriptPath, 'prompt-submit', { session_id: 's1' }); + expect(existsSync(oldFile)).toBe(true); + + await runHook(scriptPath, 'session-start', { session_id: 's1' }); + expect(existsSync(oldFile)).toBe(false); + expect(existsSync(newFile)).toBe(true); + // ③ 파일이 아닌 것은 오래돼도 그대로 둬요 + expect(existsSync(configPath)).toBe(true); + expect(existsSync(scriptPath)).toBe(true); + } finally { + await server.close(); + } + }); + it('세션 번호나 결정 사항 ID 모양이 틀리면 파일을 만들지 않아요 (경로 문자 막기)', async () => { const server = await fakeServer({ systemMessage: '화면' }); try { @@ -466,7 +500,7 @@ describe('팀원 PC 스크립트: T1 ③ 값 파일 (BDT-108)', () => { const file = join(home, '.claude', 'haribo', `t1-s1-${preview.id}.json`); expect(existsSync(file)).toBe(false); - // ③ 파일이 없는 저장(지금 T1, 안내문 2·3번)은 그대로 둬요 (PR A) + // 서버에 못 붙으면 ③ 파일이 없는 저장도 막지 않아요 (notify.error.no-block-retry) expect(await runHook(scriptPath, 'before-save', save())).toEqual({ code: 0, stdout: '', diff --git a/apps/mcp-server/src/hooks/t1-hooks.test.ts b/apps/mcp-server/src/hooks/t1-hooks.test.ts index 8cb3491..c7dfe6a 100644 --- a/apps/mcp-server/src/hooks/t1-hooks.test.ts +++ b/apps/mcp-server/src/hooks/t1-hooks.test.ts @@ -225,9 +225,15 @@ describe('after-t1: 예외 안내', () => { describe('before-save: 저장 직전', () => { const save = { ...PREVIEW, baseVersion: 2, source: 'ai_chat' }; - it('③ 값이 없으면(③을 거치지 않은 저장) 아무것도 하지 않아요 (PR A)', async () => { + it('③ 값이 없으면(③을 거치지 않은 저장) 막고 ③부터 하라고 알려요', async () => { const result = await hook('before-save', { tool_input: save }); - expect(result.json).toEqual({}); + expect(result.json.hookSpecificOutput).toMatchObject({ + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + }); + expect(result.json.hookSpecificOutput?.permissionDecisionReason).toContain( + 'show_standard_check', + ); }); it('③과 같으면 사람에게 허락을 물어요', async () => { @@ -266,7 +272,7 @@ describe('after-save: 저장 직후', () => { ], }; - it('③ 값이 없으면 ④를 띄우지 않아요. 지금 T1 hook과 두 번 뜨지 않게요', async () => { + it('③ 값이 없으면 ④를 띄우지 않아요', async () => { const result = await hook('after-save', { tool_response: saved }); expect(result.json).toEqual({}); }); diff --git a/apps/mcp-server/src/hooks/t1-hooks.ts b/apps/mcp-server/src/hooks/t1-hooks.ts index 15c2d9d..88509cb 100644 --- a/apps/mcp-server/src/hooks/t1-hooks.ts +++ b/apps/mcp-server/src/hooks/t1-hooks.ts @@ -10,8 +10,8 @@ * * 서버는 ③ 값을 기억하지 않아요. 무료 호스팅은 쉬었다 깨어나면 메모리가 비어서, ③ 값은 팀원 PC 파일에 둬요. * hook.mjs가 ③ 직후 그 파일을 남기고, 저장 직전·직후에 읽어 `t1_preview`로 붙여 보내요(10/23 결정). - * `t1_preview`가 없으면 ③을 거치지 않은 저장이라 아무것도 하지 않아요. 그래서 지금 T1(CLAUDE.md + Bash)과 화면이 두 번 뜨지 않아요. - * ③ 없는 저장을 막는 것은 MCP 안내문 2·3번을 ③을 거치게 바꾸는 PR B에서 켜요. + * `t1_preview`가 없으면 ③을 거치지 않은 저장이라 저장 직전에 막고 ③부터 다시 하게 해요(안내문 2·3·10번). + * 서버에 못 붙어 hook이 실패하면 저장은 막지 않아요 (notify.error.no-block-retry, hook.mjs). */ import type { SaveStandardInput } from '@sil/core'; import { createLogger } from '@sil/core/node'; @@ -97,7 +97,7 @@ function askJson( const reasonStep = `예면 이유를 정해요. 사람이 대화에서 한 말로 알 수 있으면 한 문장으로 정리하고, 알 수 없으면 "${T1_COPY.reasonPrompt}"를 글로 물어요(지어내지 않아요). 그다음 ${toolName('show_standard_check')}(screen: "preview")로 ③을 보여 줘요.`; -/** ② 다른 결정 사항 뒤 할 일 (CLAUDE.md T1 2~4, 8) */ +/** ② 다른 결정 사항 뒤 할 일 (예전 CLAUDE.md T1 2~4, 8) */ function conflictsContext(input: T1ConflictsInput): string { const { ask, direct, notChanged } = T1_COPY; const isRevert = input.revert !== undefined; @@ -115,7 +115,7 @@ function conflictsContext(input: T1ConflictsInput): string { ].join('\n'); } -/** ③ 등록될 모습 뒤 할 일 (CLAUDE.md T1 5~6) */ +/** ③ 등록될 모습 뒤 할 일 (예전 CLAUDE.md T1 5~6) */ function previewContext(hasTargets: boolean): string { const { preview, direct, editPrompt, cancelled } = T1_COPY; const register = hasTargets ? preview.registerWithIssues : preview.register; @@ -128,7 +128,7 @@ function previewContext(hasTargets: boolean): string { ].join('\n'); } -/** 예외 안내 뒤 할 일 (CLAUDE.md T1 9) */ +/** 예외 안내 뒤 할 일 (예전 CLAUDE.md T1 9) */ function noticeContext(input: T1NoticeInput): string { const { notices, similarAsk } = T1_COPY; switch (input.kind) { @@ -149,6 +149,9 @@ const SAVED_CONTEXT = `${SHOWN}\n하던 작업이 있으면 "${T1_COPY.saved.nex /** 예외 7 뒤 할 일 */ const SAVE_RACE_CONTEXT = `${SHOWN}\n저장하는 사이 다른 사람이 먼저 바꿔 거절됐어요. ${toolName('get_standard')}로 새 currentVersion을 읽어 ② 다른 결정 사항을 다시 보여 주고, 사람이 이미 고른 예와 정한 이유는 그대로 두고 ③부터 다시 물어요.`; +/** ③을 보여 주지 않고 저장하려 할 때 막는 이유. Claude와 사람 모두 봐요 */ +const NO_PREVIEW_REASON = `③ 등록될 모습을 사람에게 보여 주지 않은 저장이에요. ${toolName('show_standard_check')}(screen: "preview")로 ③을 먼저 보여 주고 등록하기를 받은 뒤 저장해 주세요.`; + /** 화면을 띄우지 못했을 때 Claude에게만 전하는 말 */ const NOT_SHOWN = '결정 사항 확인 화면을 만들지 못해 사람에게 보이지 않았어요. 다음 단계로 가지 말고 멈춘 뒤, 화면을 띄우지 못했다고 사람에게 알려요.'; @@ -243,8 +246,16 @@ export function createT1Hooks(options: T1HooksOptions = {}) { function beforeSave(input: T1HookInput): T1HookOutput { const preview = previewInput.safeParse(input.t1_preview); - // ③을 거치지 않은 저장은 PR A에서는 그대로 둬요 (지금 T1과 안내문 2·3번 저장) - if (!preview.success) return {}; + // ③을 거치지 않은 저장은 막아요. ③과 다른 ID로 저장할 때도 파일이 없어 여기서 막혀요 + if (!preview.success) { + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: NO_PREVIEW_REASON, + }, + }; + } const changed = diffFromPreview( preview.data, (input.tool_input ?? {}) as Partial, @@ -270,7 +281,7 @@ export function createT1Hooks(options: T1HooksOptions = {}) { input: T1HookInput, ): Promise { const preview = previewInput.safeParse(input.t1_preview); - // ③ 파일이 있을 때만 띄워요. 그래야 지금 T1 hook과 ④가 두 번 뜨지 않아요 + // ③ 파일이 있을 때만 띄워요. ③ 없는 저장은 저장 직전에 막혀서 여기까지 오지 않아요 if (!preview.success) return {}; const hookEventName = input.hook_event_name === 'PostToolUseFailure' diff --git a/apps/mcp-server/src/http.test.ts b/apps/mcp-server/src/http.test.ts index ecbc02d..476b561 100644 --- a/apps/mcp-server/src/http.test.ts +++ b/apps/mcp-server/src/http.test.ts @@ -193,9 +193,14 @@ describe('MCP 도구 (가짜 DB)', () => { it('show_standard_check: 입력만 검사하고 { ok: true }만 돌려줘요. 저장하지 않아요 (BDT-108)', async () => { const client = await connect(); const { tools } = await client.listTools(); - expect( - tools.find((tool) => tool.name === 'show_standard_check')?.annotations, - ).toMatchObject({ readOnlyHint: true, openWorldHint: false }); + const tool = tools.find(({ name }) => name === 'show_standard_check'); + expect(tool?.annotations).toMatchObject({ + readOnlyHint: true, + openWorldHint: false, + }); + // 미뤄 불러오는 도구 설명은 16,384자에서 잘려요. 언제 쓰는지는 맨 앞에 둬요 (code.claude.com/docs/en/mcp) + expect(tool?.description?.length).toBeLessThanOrEqual(16_384); + expect(tool?.description?.slice(0, 200)).toContain('만들기 전에'); const preview = { id: 'order.coupon.nullable', diff --git a/apps/mcp-server/src/instructions.test.ts b/apps/mcp-server/src/instructions.test.ts new file mode 100644 index 0000000..a636afe --- /dev/null +++ b/apps/mcp-server/src/instructions.test.ts @@ -0,0 +1,43 @@ +/** + * 서버 안내문 길이를 확인해요 (BDT-108). + * Claude Code는 서버 안내문을 4,096자에서 잘라요. 안내문 뒤쪽이 잘려도 사용법(1~10번)과 항상 적용 결정 사항은 남아야 해요. + */ +import { setDbForTesting } from '@sil/core/node'; +import { createFakeDb, standardRows } from '@sil/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { clearInstructionsCache, getInstructions } from './instructions.ts'; + +/** Claude Code가 서버 안내문을 자르는 길이 (code.claude.com/docs/en/mcp "Scale with MCP tool search") */ +const INSTRUCTIONS_LIMIT = 4096; +/** 사람 이름이 있으면 맨 앞에 붙는 "답을 기다리는 확인할 변경" 줄의 넉넉한 길이 */ +const PENDING_LINE = 200; + +afterEach(() => { + clearInstructionsCache(); + setDbForTesting(undefined); +}); + +describe('서버 안내문', () => { + it('항상 적용 결정 사항이 10개(최대)이고 값이 길어도 4,096자 안에 들어요', async () => { + // ID는 길게, 값은 안내문이 자르는 160자보다 길게 넣어요 + const rows = Array.from({ length: 10 }, (_, i) => + standardRows({ + id: `notify.terminal.new-after-answered-${i}`, + area: '알리기', + alwaysOn: true, + versions: [{ title: '제목', value: '긴 값 '.repeat(100) }], + }), + ); + setDbForTesting( + createFakeDb({ + standards: rows.map((row) => row.standard), + standard_versions: rows.flatMap((row) => row.versions), + standards_latest: rows.map((row) => row.latest), + }).client, + ); + const text = await getInstructions(null); + expect(text).toContain('10. 작업이 결정 사항과 다르게 되면'); + expect(text).toContain('notify.terminal.new-after-answered-9'); + expect(text.length + PENDING_LINE).toBeLessThanOrEqual(INSTRUCTIONS_LIMIT); + }); +}); diff --git a/apps/mcp-server/src/instructions.ts b/apps/mcp-server/src/instructions.ts index 440400a..c577eae 100644 --- a/apps/mcp-server/src/instructions.ts +++ b/apps/mcp-server/src/instructions.ts @@ -27,8 +27,8 @@ const MAX_AFFECTED = 10; const USAGE = [ 'haribo는 팀 위키예요. 코드나 화면을 바꾸기 전에 관련 결정 사항을 먼저 찾아요.', '1. 작업을 시작할 때 get_index로 영역을 보고, search_standards(keyword, area, codePath)로 관련 결정 사항을 찾아요.', - '2. 사람에게 묻기 전에 결정 사항을 먼저 찾아요. 없으면 사람에게 묻고, 답을 save_standard로 새 결정 사항(baseVersion 0)으로 저장해요.', - '3. 결정 사항을 고칠 때는 get_standard로 currentVersion과 alwaysOn을 보고 그대로 baseVersion, alwaysOn에 넣어요. 이유는 지어내지 말고 사람에게 물어요.', + '2. 사람에게 묻기 전에 결정 사항을 먼저 찾아요. 없으면 사람에게 묻고, 답을 save_standard로 새 결정 사항(baseVersion 0)으로 저장해요. 저장 전에는 10번처럼 ③을 먼저 보여 줘요.', + '3. 결정 사항을 고칠 때는 get_standard로 currentVersion과 alwaysOn을 보고 그대로 baseVersion, alwaysOn에 넣어요. 이유는 지어내지 말고 사람에게 물어요. 저장 전에는 10번처럼 ③을 먼저 보여 줘요.', '4. 저장이 거절되면 메시지에 적힌 대로 해요. 잠긴 결정 사항은 고치지 말고 사람에게 알려요.', '5. 결정 사항 폐기는 사람이 해요(sil deprecate). 폐기가 필요해 보이면 사람에게 알려요.', '6. save_standard와 revert는 CHANGELOG 한 줄(changelogLine)을 돌려줘요. 작업 중인 저장소에 team-wiki/CHANGELOG.md가 있으면 그 줄을 맨 아래에 붙여요.', @@ -37,6 +37,8 @@ const USAGE = [ '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로 불러요. 코드를 보고 반영했다고 짐작해서 표시하지 않아요.', + // T1 절차는 도구 설명과 hooks가 넘겨요. 여기에는 언제 부르는지만 둬요 (BDT-108) + '10. 작업이 결정 사항과 다르게 되면 만들기 전에 show_standard_check를 불러 사람에게 먼저 물어요 (work.wiki.ask-before-change). 사람이 결정 사항이나 위키를 바꿔 달라고 할 때도 이 도구로 시작해요. save_standard로 저장하기 전에는 늘 이 도구로 ③ 등록될 모습을 먼저 보여 줘요. ③ 없이 저장하면 막혀요.', ].join('\n'); const cache = new Map(); diff --git a/apps/mcp-server/src/tools/detect/show-standard-check.ts b/apps/mcp-server/src/tools/detect/show-standard-check.ts index 6e54da1..c589acb 100644 --- a/apps/mcp-server/src/tools/detect/show-standard-check.ts +++ b/apps/mcp-server/src/tools/detect/show-standard-check.ts @@ -6,7 +6,7 @@ import { defineTool } from '../../lib/define-tool.ts'; * T1 결정 사항 확인 화면을 띄우는 도구예요 (BDT-108). * 화면은 도구 직후 hook(`hooks/t1-hooks.ts`)이 이 입력으로 만들어요. 도구는 입력만 검사하고 `{ ok: true }`를 돌려줘요. * 저장하지 않고(저장은 save_standard만), 결정 사항을 읽어 돌려주지도 않아요 (wiki.mcp.five-tools v2). - * 입력 검사는 `apps/detect/src/t1/conflicts.ts`·`notice.ts`·`preview.ts`의 손 검사를 zod로 옮겼어요. + * 입력 검사는 예전 Bash 명령(`apps/detect/src/t1/conflicts.ts` 등, BDT-108에서 지움)의 손 검사를 zod로 옮겼어요. */ const filled = z.string().trim().min(1); @@ -102,12 +102,54 @@ export const showStandardCheckInput = z export type ShowStandardCheckInput = z.output; export type T1PreviewInput = z.output; +/** + * 도구 설명. 예전 CLAUDE.md T1 1~9번 중 Claude가 이 도구를 부르기 전에 할 일을 옮겼어요. + * 부른 뒤 단계(묻기, 이유, ③ 질문, 저장 뒤 한 줄)는 hook(`hooks/t1-hooks.ts`)이 그때그때 넘겨요. + * 미뤄 불러오는 도구라 16,384자까지 보여요. 검색에 걸리게 앞부분에 언제 쓰는지 둬요 (code.claude.com/docs/en/mcp) + */ +const DESCRIPTION = `팀 결정 사항(위키)과 다른 결과물을 만들게 될 때, 만들기 전에 사람에게 "결정 사항을 바꾸는지" 묻는 T1 확인 화면을 띄워요 (work.wiki.ask-before-change). 결정 사항을 저장(save_standard)하기 전에도 늘 이 도구로 ③ 등록될 모습을 먼저 보여 줘요. ③ 없이 저장하면 막혀요. +화면은 hook이 사람에게 바로 보여 주고, 이 도구는 { ok: true }만 돌려줘요. 저장하지 않고 결정 사항을 읽어 오지도 않아요. 조회용으로 쓰지 않아요. +화면을 띄운 뒤에는 화면을 띄웠다거나 무엇을 보여 줬는지 설명하는 글을 쓰지 않아요. 다음 할 일과 물을 질문은 hook이 글자 그대로 알려 줘요. + +언제 부르나요 +- 작업 중에 알아챘을 때(trigger "agent"): search_standards·get_standard로 읽은 결정 사항과 다르게 만들게 되면, 코드를 쓰기 전에 불러요. 낱말만 겹치는 결정 사항은 다른 게 아니에요. +- 직접 요청(trigger "direct"): 사람이 "결정 사항"이나 "위키"를 바꿔 달라고 분명히 말했을 때만이에요(detect.ask.direct-request-skip-warn). "32자로 자르는 함수를 만들어 줘"처럼 코드만 말하면 agent예요. vN(또는 이전 버전 = currentVersion - 1)으로 되돌려 달라는 요청도 직접 요청이에요. + +순서 +1. get_standard(historyLimit: 50)로 지금 제목·값·alwaysOn·currentVersion·적용 범위(versions[0].scope)를 읽어요. search_standards나 get_standard가 실패하면 "다른 결정 사항 없음"으로 넘어가지 말고 screen "notice"에 { kind: "searchFailed", trigger }를 넘겨요. +2. 되돌림 확인(detect.change.revert-mark): 바꿀 값을 지금 버전이 아닌 예전 버전들과 비교해요. 띄어쓰기·끝 마침표·전각 글자만 다르거나, 글자는 달라도 뜻이 같으면(숫자·범위·조건·대상이 모두 같을 때만) 되돌림이에요. 되돌림이면 바꿀 값을 그 버전의 값으로 글자 그대로 바꾸고, 아래 conflicts에 revert를 더해요. 저장은 그대로 save_standard예요(revert 도구를 쓰지 않아요). +3. screen "conflicts"(② 다른 결정 사항)를 불러요. + - trigger, request(부탁받은 작업이나 요청을 한 줄로), conflicts(바꾸려는 결정 사항 kind "change"가 맨 앞, 바꾸면 함께 어긋나는 다른 결정 사항은 kind "mismatch"). + - 항목마다 id·title·kind·why. why는 어떻게 바뀌는지만 짧게 쓴 한 문장이에요. change는 지금 값과 바꿀 값만 써서 "40자 이하로 정한 길이를 32자 이하로 다시 정해요"처럼, mismatch는 무엇이 어긋나는지만 써서 "결정 사항 제목(최대 40자)을 그대로 쓰면 32자에서 잘려요"처럼 써요. 작업을 다시 설명하지 않아요. + - 되돌림이면 change 항목에 version(currentVersion)을 넣고, get_standard로 읽은 되돌아갈 버전의 version·title·reason·changedBy·createdAt을 revert로 글자 그대로 넣어요. + - 직접 요청인데 기존 결정 사항이 없으면(search_standards로 찾아도 없음) conflicts를 빈 목록으로 넘겨요. +4. 그다음은 hook이 알려 줘요: 묻기(예/아니요) → 이유 → screen "preview"(③ 등록될 모습) → 등록하기면 save_standard. + - preview에는 id·title·value·reason·alwaysOn을 넣어요. 바꾸는 사람은 서버가 알아서 넣지 않아요. 새 결정 사항이면 isNew: true, ID는 data.standard.id-format대로, alwaysOn은 사람이 말하지 않았으면 false예요. + - 이유는 사람이 대화에서 한 말로 알 수 있으면 한 문장으로 정리하고, 알 수 없으면 사람에게 물어요. 근거 없이 지어내지 않아요. + +저장(save_standard) 값 +- ③에서 보여 준 id·title·value·reason·alwaysOn을 그대로 넣어요. 다르면 저장 직전 hook이 막아요. 수정하기로 값이 바뀌면 ③을 다시 보여 줘요. +- 고칠 때: baseVersion은 1에서 읽은 currentVersion, alwaysOn과 scope는 지금 값 그대로(scope를 비우면 적용 범위가 지워져요. 지금 값이 null이면 칸을 빼요). +- 새 결정 사항: baseVersion 0, scope는 빼요. +- source는 "ai_chat", aiDetected는 trigger가 agent면 true, direct면 false예요 (D61). +- 결과의 changelogLine은 작업 중인 저장소에 team-wiki/CHANGELOG.md가 있으면 맨 아래에 그대로 붙여요. 위키 파일의 값·버전은 고치지 않아요. + +예외 (screen "notice") +- 완전히 같음: 직접 요청한 값이 그 결정 사항의 지금 값과 같으면 { kind: "same", existing: { id, title, value } }를 넘기고 끝내요. 저장하지 않아요. +- 되돌릴 값이 지금과 같음: vN으로 되돌려 달라는데 지금 값이 이미 vN의 값과 같으면(N이 지금 버전일 때도) { kind: "revertSame", version: N, versionValue: vN의 값, existing: 지금 버전 }을 넘겨요. +- 뜻이 같음: 새 결정 사항을 만들어 달라는데 다른 ID의 결정 사항이 같은 뜻이면 { kind: "similar", existing }을 넘겨요. 사람이 아니요를 고르면 { kind: "similarDeclined" }를 넘겨요. +- 저장하는 사이 바뀜(VERSION_CONFLICT)은 저장 직후 hook이 바로 띄워요. 이 도구로 넘기지 않아요. + +지킬 것 +- 아니요·취소하기·선택 창 Esc는 그 프롬프트로 시작한 작업 하나에만 적용돼요. 새 프롬프트에서 다시 결정 사항과 다르게 만들게 되면 1부터 다시 해요. +- 사람이 "40자로 작업해 줘"처럼 어떻게 할지 다시 말할 때만 그대로 진행해요. "계속해 줘"처럼 분명하지 않으면 작업하지 않고 기다려요. +- 저장하지 않았으면 결정 사항과 다른 값으로 코드를 쓰지 않아요. +- 잠긴 결정 사항은 고치지 않고 사람에게 알려요.`; + export const showStandardCheck = defineTool({ name: 'show_standard_check', title: '결정 사항 확인 화면', - // 언제 부르는지와 넘길 값은 PR B에서 채워요. 그 전에는 CLAUDE.md T1(Bash)이 화면을 띄워서 부르지 않게 둬요 - description: - '결정 사항 확인 화면을 띄우는 도구예요. 아직 준비 중이라 부르지 않아요.', + description: DESCRIPTION, inputSchema: showStandardCheckInput, annotations: { readOnlyHint: true, openWorldHint: false }, handler: () => ({ ok: true }), diff --git a/packages/t1/README.md b/packages/t1/README.md index 96f80b7..0eb1d3f 100644 --- a/packages/t1/README.md +++ b/packages/t1/README.md @@ -2,7 +2,7 @@ T1(결정 사항과 다르면 먼저 묻기)의 화면 모양, 문구, 저장 값이에요. 담당: **경하님** (문구는 **부건님**, SIL-66) -`apps/detect`의 Bash 명령·hooks와 MCP 서버가 함께 가져다 써서 `apps/detect/src/t1/`에서 옮겼어요 (BDT-108, D49). 화면은 저장하지 않고 글자만 만들어요. ③ 받는 이슈만 팀 DB를 읽어요. +MCP 서버(`show_standard_check` 도구와 T1 hooks)가 가져다 써서 `apps/detect/src/t1/`에서 옮겼어요 (BDT-108, D49). 화면은 저장하지 않고 글자만 만들어요. ③ 받는 이슈만 팀 DB를 읽어요. | 파일 | 내용 | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -12,4 +12,4 @@ T1(결정 사항과 다르면 먼저 묻기)의 화면 모양, 문구, 저장 | `src/issues.ts` | ③ 이슈 표에 넣을 받는 이슈를 core `listIssues()`로 팀 DB에서 읽어요(팀원 `.env`의 Supabase 주소·공용 키). 알리기와 같은 규칙(담당자 있음, 바꾼 본인 뺌). 못 읽으면 표 칸 이름만 보여요 | | `src/text.ts` | 화면 칸 계산(한글·이모지는 두 칸), 줄 나누기와 자르기 | -테스트는 `src/t1.test.ts`예요 (`pnpm test`). Bash 명령과 hooks 테스트는 `apps/detect/src/t1/t1.test.ts`에 남아 있어요. +테스트는 `src/t1.test.ts`예요 (`pnpm test`). hooks 테스트는 `apps/mcp-server/src/hooks/t1-hooks.test.ts`예요. diff --git a/packages/t1/src/copy.ts b/packages/t1/src/copy.ts index f7e9418..77f9686 100644 --- a/packages/t1/src/copy.ts +++ b/packages/t1/src/copy.ts @@ -3,7 +3,7 @@ import type { Standard, StandardVersion } from '@sil/core'; /** * T1 "결정 사항 변경 확인" 문구. 착수 안내 3절 T1에서 시작해 FigJam "T1 결정 사항 등록"(10/13)에 맞췄어요. * 부건님과 정하는 문구라 여기에만 둬요. - * 에이전트는 CLAUDE.md "팀 위키 규칙"을 따라 이 파일을 읽고 그대로 보여 줘요. + * MCP 서버 hooks(`apps/mcp-server/src/hooks/t1-hooks.ts`)가 화면을 만들고, 물을 질문을 이 글자 그대로 Claude에게 넘겨요. * 화면·도구 문구에서는 "기준" 대신 "결정 사항"이라고 써요 (copy.term.standard). * {자리}에는 get_standard로 읽은 값과 바꿀 값을 넣어요. */ @@ -30,7 +30,7 @@ export const T1_COPY = { typedReply: '예 또는 아니요로 골라 주세요.', }, /** - * 다른 결정 사항 화면. hook(`hook-conflicts.ts`)이 `screens.ts`의 모양으로 띄워요 (프로토타입 show_conflicts, 10/14). + * 다른 결정 사항 화면. hook(`after-t1`)이 `screens.ts`의 모양으로 띄워요 (프로토타입 show_conflicts, 10/14). * 머리표와 첫 줄 이름은 T1이 어떻게 시작됐는지(`T1Trigger`)에 따라 달라요: 에이전트가 알아챈 작업 / 사람이 직접 한 요청. * 구분은 바꾸려는 결정 사항이면 변경, 바꾸면 함께 어긋나는 다른 결정 사항이면 어긋남이에요. * "충돌"은 용어집에서 동시 수정을 뜻해서 "달라요"를 써요 (10/13) @@ -70,8 +70,8 @@ export const T1_COPY = { reasonPrompt: '이 결정 사항을 바꾸는 이유를 작성해 주세요. 작성해 주신 내용을 기반으로 정리해 드릴게요.', /** - * ③ 등록될 모습. 본문은 hook(`hook-preview.ts`)이 `screens.ts`의 모양으로 띄우고, 그 뒤 AskUserQuestion으로 물어요. - * 부건님 프로토타입 "T1 결정 사항 등록"(10/14)과 같아요. 이슈 표는 `preview.ts`가 팀 DB에서 읽은 받는 이슈로 채워요 + * ③ 등록될 모습. 본문은 hook(`after-t1`)이 `screens.ts`의 모양으로 띄우고, 그 뒤 AskUserQuestion으로 물어요. + * 부건님 프로토타입 "T1 결정 사항 등록"(10/14)과 같아요. 이슈 표는 서버가 팀 DB에서 읽은 받는 이슈로 채워요 */ preview: { header: '결정 사항 등록', @@ -86,7 +86,7 @@ export const T1_COPY = { noTargets: '연결된 이슈가 없어서 알림을 받는 사람은 없어요.', register: { label: '등록하기', description: '결정 사항을 저장해요' }, /** - * 받는 이슈가 있을 때의 등록하기. `preview.ts` 출력의 "알림 받는 이슈"가 1개 이상이면 이것을, 아니면 `register`를 써요 (프로토타입 10/13) + * 받는 이슈가 있을 때의 등록하기. 받는 이슈가 1개 이상이면 hook이 이것을, 아니면 `register`를 넘겨요 (프로토타입 10/13) */ registerWithIssues: { label: '등록하기', @@ -127,7 +127,7 @@ export const T1_COPY = { revertAsk: '기존 결정 사항을 되돌리는 작업이에요. 그대로 진행할까요?', }, /** - * 예외 안내(10/14 확정, 결정기록 4-2). hook(`hook-notice.ts`)이 `screens.ts`의 모양으로 띄워요. + * 예외 안내(10/14 확정, 결정기록 4-2). hook(`after-t1`)이 `screens.ts`의 모양으로 띄워요. * 막대 색: 조사 실패·완전히 같음은 빨강, 뜻이 같음·저장하는 사이 바뀜은 노랑, 뜻이 같음 → 아니요는 기본 글자색 */ notices: { @@ -167,8 +167,8 @@ export const T1_COPY = { no: { label: '아니요', description: '등록하지 않아요' }, }, /** - * ④ 등록 완료. 저장 직후 hook(`hook-saved.ts`)이 `screens.ts`의 모양으로 띄워요 (프로토타입 10/14). - * 받는 이슈 수는 ③에서 `preview.ts`가 읽어 둔 값이에요. 못 읽었으면 그 줄을 빼요 + * ④ 등록 완료. 저장 직후 hook(`after-save`)이 `screens.ts`의 모양으로 띄워요 (프로토타입 10/14). + * 받는 이슈 수는 저장 직후 서버가 다시 읽어요. 못 읽었으면 그 줄을 빼요 */ saved: { head: '✓ 결정 사항을 등록했어요.', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bd576d..0b24b08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: '@sil/core': specifier: workspace:* version: link:../../packages/core - '@sil/t1': - specifier: workspace:* - version: link:../../packages/t1 zod: specifier: ^4.2.0 version: 4.6.5