From 03c04f595e41a6a4c5710b4c6b7d535219f6dfde Mon Sep 17 00:00:00 2001 From: k0nghaa Date: Thu, 15 Oct 2026 16:26:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(detect):=20T1=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=205=EA=B0=80=EC=A7=80=EB=A5=BC=20hook?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=9D=84=EC=9A=B0=EA=B3=A0=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=2011=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8=20(SIL-66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10/14 확정 예외를 notice.ts 명령 + hook-notice.ts로 띄워요. - 예외 3 조사 실패(경우 1·2): 빨간 막대 "다른 결정 사항을 조회하지 못해 하던 작업을/등록을 멈췄어요." - 충돌 3 완전히 같음: 빨간 막대 + 기존 결정 사항 줄, 선택지 없이 끝 - 충돌 3 뜻이 같음: 노란 막대 + 기존 결정 사항 줄 → "그래도 새 결정 사항으로 등록할까요?", 아니요면 기본 글자색 막대 - 예외 7 저장하는 사이 바뀜: VERSION_CONFLICT면 노란 막대 뒤 다른 결정 사항 확인부터 다시 - 예외 11: 아니요는 그 프롬프트의 작업에만 적용, 새 프롬프트면 다시 물어요 (CLAUDE.md) Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 4 + CLAUDE.md | 7 +- apps/detect/README.md | 2 + apps/detect/src/t1/copy.ts | 31 +++++ apps/detect/src/t1/hook-notice.ts | 28 +++++ apps/detect/src/t1/notice.ts | 51 +++++++++ apps/detect/src/t1/pending.ts | 34 ++++-- apps/detect/src/t1/preview-command.ts | 13 +++ apps/detect/src/t1/screens.ts | 52 ++++++++- apps/detect/src/t1/t1.test.ts | 106 ++++++++++++++++++ ...05\353\252\205\354\204\270\354\204\234.md" | 2 +- 11 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 apps/detect/src/t1/hook-notice.ts create mode 100644 apps/detect/src/t1/notice.ts diff --git a/.claude/settings.json b/.claude/settings.json index 7c60aca..b070be6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,10 @@ { "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\"" } ] }, diff --git a/CLAUDE.md b/CLAUDE.md index 1b9aa61..820ee2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ 문구는 `apps/detect/src/t1/copy.ts`의 `T1_COPY`를 글자 그대로 쓴다(바꿔 말하지 않는다). 선택지는 거기 있는 것만 쓰고 새로 만들지 않는다. 1. haribo `get_standard`로 지금 영역·제목·값·`alwaysOn`·`currentVersion`과 적용 범위(`versions[0].scope`)를 읽는다. 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`(왜 그렇게 판단했는지 한 문장)를 넣고, 낱말만 겹치는 결정 사항은 넣지 않는다. 화면을 요약하거나 "화면을 띄웠어요"처럼 설명하는 글을 쓰지 않고 바로 다음으로 간다(③도 같다). 그다음 `ask`를 AskUserQuestion으로 묻는다(header는 `ask.header`, 선택지는 `yes`·`no`의 label·description 그대로, 질문에 화면 내용을 다시 적지 않는다). 선택지 대신 글로 답하면 `ask.typedReply`를 쓰고 같은 질문을 다시 묻는다. - 3. 아니요면 저장하지 않고 `notChanged`를 보여 준 뒤 작업을 멈추고 기다린다. 지금 값대로도 코드를 쓰지 않는다. 사람이 "40자로 작업해 줘"처럼 어떻게 할지 다시 말할 때만 그대로 진행한다. + 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`) (`apps/detect/src/t1/save-input.ts`와 같은 값, D61). 저장 결과의 `changelogLine`을 team-wiki/CHANGELOG.md 맨 아래에 그대로 붙인다(work.wiki.changelog). 위키 파일의 값·버전은 고치지 않는다 (위키 문서 버전과 DB 버전은 별개). @@ -24,6 +24,11 @@ - 기존 결정 사항이 있으면: 1로 읽고, 2의 다른 결정 사항 화면은 `trigger: "direct"`로 띄우되 `ask`는 묻지 않고 4로 간다(3도 건너뛴다). 5의 선택 창 질문은 `preview.confirm` 대신 `direct.confirm`. - 기존 결정 사항이 없으면(`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"}}`를 띄우고 선택지 없이 끝낸다. 저장하지 않는다. + - 뜻이 같음(충돌 3): 새 결정 사항을 만들어 달라는데 다른 ID의 결정 사항이 같은 뜻이면 `{"kind":"similar","existing":{…}}`를 띄우고 `similarAsk`를 AskUserQuestion으로 묻는다. 예면 새 결정 사항으로 4부터 하고(`isNew`), 아니요면 `{"kind":"similarDeclined"}`를 띄우고 끝낸다. + - 저장하는 사이 바뀜(예외 7): `save_standard`가 `VERSION_CONFLICT`로 거절되면 `{"kind":"saveRace"}`를 띄우고 1부터 다시 한다. `get_standard`로 새 `currentVersion`을 읽어 2의 다른 결정 사항 화면을 다시 띄우고, 사람이 이미 고른 예와 정한 이유는 그대로 두고 5(③)부터 다시 묻는다. - 어느 경우든 6에서 저장하지 않았으면 결정 사항과 다른 값으로 코드를 쓰지 않는다. - (T1로 저장한 경우는 위 6을 따르고 이 줄은 하지 않는다) 기준을 바꾸기로 하면: 해당 기준의 값·이유·근거를 고치고 버전을 1 올린 뒤, CHANGELOG.md 맨 아래에 한 줄을 남긴다. 형식: 날짜 시각 | 누가 | 기준 ID 이전→새 버전 | 이전 값 → 새 값 | 이유 | 출처(코드/AI 대화/Figma/회의/Slack) diff --git a/apps/detect/README.md b/apps/detect/README.md index 7d6106a..97137a5 100644 --- a/apps/detect/README.md +++ b/apps/detect/README.md @@ -20,6 +20,7 @@ | `src/t1/screens.ts` | hooks가 띄우는 화면 모양(③ 등록될 모습, 다른 결정 사항 화면). 부건님 프로토타입 "T1 결정 사항 등록"(10/14)을 옮겼어요. 칸 계산은 `text.ts` | | `src/t1/conflicts.ts` | 다른 결정 사항 화면 값을 임시 파일(`haribo-t1-conflicts-.json`)에 들고 있는 명령. hook이 띄운 뒤 바로 지워요 (경우 1·2) | | `src/t1/issues.ts` | ③ 이슈 표에 넣을 받는 이슈를 core `listIssues()`로 팀 DB에서 읽어요(팀원 `.env`의 Supabase 주소·공용 키). 알리기와 같은 규칙(담당자 있음, 바꾼 본인 뺌). 못 읽으면 표 칸 이름만 보여요 | +| `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 오류가 나지 않아요 | @@ -37,6 +38,7 @@ | `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` 직후 | ③을 거친 저장이 성공하면 ④ 등록 완료(초록 첫 줄, 제목·항상 적용, 보낸 이슈 수)를 띄우고 저장한 결정 사항의 임시 값만 지워요 | 안전장치로 `.claude/settings.json`의 `permissions.ask`에 `save_standard`와 `revert`를 넣어, 저장하기 전에 Claude Code가 한 번 더 허락을 받아요. auto mode에서도 뜨고, 허락 창에서 "Yes, and don't ask again"을 골라도 다음에 다시 물어요 (10/13 시험). diff --git a/apps/detect/src/t1/copy.ts b/apps/detect/src/t1/copy.ts index d0af640..568f79a 100644 --- a/apps/detect/src/t1/copy.ts +++ b/apps/detect/src/t1/copy.ts @@ -105,6 +105,37 @@ export const T1_COPY = { confirm: '달라지게 되는 기존 결정 사항 내역을 확인했나요? 이대로 등록할까요?', }, + /** + * 예외 안내(10/14 확정, 결정기록 4-2). hook(`hook-notice.ts`)이 `screens.ts`의 모양으로 띄워요. + * 막대 색: 조사 실패·완전히 같음은 빨강, 뜻이 같음·저장하는 사이 바뀜은 노랑, 뜻이 같음 → 아니요는 기본 글자색 + */ + notices: { + /** 예외 3: 결정 사항을 불러오지 못하면 "다른 결정 사항 없음"으로 넘어가지 않고 멈춰요 */ + searchFailed: { + head: { + agent: '다른 결정 사항을 조회하지 못해 하던 작업을 멈췄어요.', + direct: '다른 결정 사항을 조회하지 못해 등록을 멈췄어요.', + }, + body: '잠시 뒤 다시 시도해 보시고 반복된다면 담당자에게 알려 주세요.', + }, + /** 충돌 3: 같은 ID·같은 값이면 선택지 없이 끝내요 */ + same: '같은 결정 사항이 있어서 등록하지 않았어요.', + /** 충돌 3: AI가 같은 뜻이라고 보면 새로 등록할지 물어요 (제안 문구, 10/14) */ + similar: '같은 뜻의 결정 사항이 이미 있어요.', + similarDeclined: '등록하지 않았어요. 기존 결정 사항을 그대로 둘게요.', + /** 예외 7: 저장이 거절되면(VERSION_CONFLICT) 다른 결정 사항 확인부터 다시 해요 */ + saveRace: { + head: '저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿨어요.', + body: '바뀐 내용을 다시 확인할게요.', + }, + }, + /** 충돌 3 뜻이 같음 뒤 묻기 (AskUserQuestion). 보기 이름은 짧게, 설명은 아래 줄 (제안 문구, 10/14) */ + similarAsk: { + header: '결정 사항 등록', + question: '그래도 새 결정 사항으로 등록할까요?', + yes: { label: '예', description: '등록해요' }, + no: { label: '아니요', description: '등록하지 않아요' }, + }, /** * ④ 등록 완료. 저장 직후 hook(`hook-saved.ts`)이 `screens.ts`의 모양으로 띄워요 (프로토타입 10/14). * 받는 이슈 수는 ③에서 `preview.ts`가 읽어 둔 값이에요. 못 읽었으면 그 줄을 빼요 diff --git a/apps/detect/src/t1/hook-notice.ts b/apps/detect/src/t1/hook-notice.ts new file mode 100644 index 0000000..ff1f772 --- /dev/null +++ b/apps/detect/src/t1/hook-notice.ts @@ -0,0 +1,28 @@ +/** + * 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('./screens.ts'); +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/notice.ts b/apps/detect/src/t1/notice.ts new file mode 100644 index 0000000..3ce0419 --- /dev/null +++ b/apps/detect/src/t1/notice.ts @@ -0,0 +1,51 @@ +/** + * 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 필요) / similarDeclined / saveRace + */ +import { readFileSync } from 'node:fs'; +import { STANDARD_ID_PATTERN } from '@sil/core'; +import { T1_COPY } from './copy.ts'; +import { writeNotice } from './pending.ts'; +import { NOTICE_DONE } from './preview-command.ts'; +import type { T1NoticeInput } from './screens.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 'similarDeclined': + case 'saveRace': + return true; + default: + return false; + } +} + +if (!isValid(input)) { + console.error( + 'kind(searchFailed|same|similar|similarDeclined|saveRace)와 그 kind에 필요한 칸(trigger 또는 existing의 id·title·value)이 있어야 해요', + ); + 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 index 07dde09..f83c1dc 100644 --- a/apps/detect/src/t1/pending.ts +++ b/apps/detect/src/t1/pending.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { STANDARD_ID_PATTERN, type SaveStandardInput } from '@sil/core'; import type { T1Target } from './issues.ts'; -import type { T1ConflictsInput } from './screens.ts'; +import type { T1ConflictsInput, T1NoticeInput } from './screens.ts'; /** * 사람에게 ③으로 보여 준 값. 모양은 save_standard 입력에서 골라 써요 (타입은 core에만). @@ -58,33 +58,45 @@ export function diffFromPending( } /** - * 다른 결정 사항 화면 값도 같은 임시 폴더에 둬요 (SIL-94 직접 요청). + * 다른 결정 사항 화면과 예외 안내 값도 같은 임시 폴더에 둬요 (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}$/; /** 표 형식이 맞을 때만 파일 경로를 줘요 (경로 문자 막기) */ -export function conflictsPath(token: string): string | null { +function shownPath(kind: ShownKind, token: string): string | null { return TOKEN_PATTERN.test(token) - ? join(tmpdir(), `haribo-t1-conflicts-${token}.json`) + ? join(tmpdir(), `haribo-t1-${kind}-${token}.json`) : null; } /** 값을 쓰고 표를 돌려줘요 */ -export function writeConflicts(input: T1ConflictsInput): string { +function writeShown(kind: ShownKind, input: unknown): string { const token = randomUUID(); - writeFileSync(conflictsPath(token) as string, JSON.stringify(input, null, 2)); + writeFileSync( + shownPath(kind, token) as string, + JSON.stringify(input, null, 2), + ); return token; } /** 읽고 바로 지워요. 없으면 null */ -export function takeConflicts( - token: string | undefined, -): T1ConflictsInput | null { - const path = token ? conflictsPath(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 T1ConflictsInput; + 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 index 4932138..dbf4889 100644 --- a/apps/detect/src/t1/preview-command.ts +++ b/apps/detect/src/t1/preview-command.ts @@ -10,6 +10,9 @@ 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 시험) @@ -37,6 +40,7 @@ const t1Command = (file: string) => ); 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); @@ -86,3 +90,12 @@ export function findPreviewId(stdout: string): string | 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/screens.ts b/apps/detect/src/t1/screens.ts index 2c29245..b9da24d 100644 --- a/apps/detect/src/t1/screens.ts +++ b/apps/detect/src/t1/screens.ts @@ -3,7 +3,7 @@ * `previewScreen`·`conflictsScreen`을 옮겼어요. 문구는 `copy.ts`에만 있어요. * Claude Code가 hook 글을 회색으로 감싸서, 강조할 글만 색 코드로 꾸며요. */ -import type { Standard } from '@sil/core'; +import type { Standard, StandardVersion } from '@sil/core'; import { T1_COPY, type T1PreviewValues, yesNo } from './copy.ts'; import type { T1Target } from './issues.ts'; import type { T1Trigger } from './save-input.ts'; @@ -50,10 +50,13 @@ export const T1_COLORS = { green: '32', red: '31', } as const; -type BarColor = Exclude; +/** plain은 터미널 기본 글자색 막대예요 (뜻이 같음 → 아니요, 10/14) */ +type BarColor = Exclude | 'plain'; /** 막대 머리표: 같은 색에 반전 7을 더한 칸 하나(바탕색) + 빈칸 + 그 색 글자. T2~T5 상태 줄과 같은 모양 (10/13) */ -const colorBar = (color: BarColor, text: string) => - `${paint(`7;${T1_COLORS[color]}`, ' ')} ${paint(T1_COLORS[color], text)}`; +const colorBar = (color: BarColor, text: string) => { + const code = color === 'plain' ? '39' : T1_COLORS[color]; + return `${paint(`7;${code}`, ' ')} ${paint(code, text)}`; +}; /** 선은 어디서나 1칸인 영문 하이픈 (10/13) */ const RULE = '-'; @@ -123,6 +126,47 @@ export function previewScreen( return lines.join('\n'); } +/** 예외 안내에 넣을 값 (10/14 확정 예외) */ +export type T1NoticeInput = + | { kind: 'searchFailed'; trigger: T1Trigger } + | { + kind: 'same' | 'similar'; + /** 이미 있는 결정 사항 (get_standard의 최신 버전) */ + existing: Pick & Pick; + } + | { kind: 'similarDeclined' } + | { kind: 'saveRace' }; + +/** 예외 안내: 색 막대 한 줄과 설명 한 줄 (프로토타입 failFlow·sameFlow·race) */ +export function noticeScreen(input: T1NoticeInput): string { + const { notices } = T1_COPY; + switch (input.kind) { + case 'searchFailed': + return [ + colorBar('red', notices.searchFailed.head[input.trigger]), + ` ${notices.searchFailed.body}`, + ].join('\n'); + case 'same': + case 'similar': { + const { existing } = input; + return [ + input.kind === 'same' + ? colorBar('red', notices.same) + : colorBar('yellow', notices.similar), + // 이미 있는 결정 사항: 굵은 [영역] 제목 · 값 (프로토타입 existingLine) + ` ${strong(areaTitle(existing.id, existing.title), true)}${strong(` · ${existing.value}`)}`, + ].join('\n'); + } + case 'similarDeclined': + return colorBar('plain', notices.similarDeclined); + case 'saveRace': + return [ + colorBar('yellow', notices.saveRace.head), + ` ${paint(T1_COLORS.yellow, notices.saveRace.body)}`, + ].join('\n'); + } +} + /** * ④ 등록 완료: 초록 첫 줄, [영역] 제목 · (새 결정 사항 ·) 항상 적용, 받는 이슈 줄 (프로토타입 10/14). * targets는 ③에서 읽어 둔 받는 이슈예요. null이면(팀 DB를 읽지 못함) 받는 이슈 줄을 빼요 diff --git a/apps/detect/src/t1/t1.test.ts b/apps/detect/src/t1/t1.test.ts index 35f75ae..6e7440f 100644 --- a/apps/detect/src/t1/t1.test.ts +++ b/apps/detect/src/t1/t1.test.ts @@ -9,16 +9,20 @@ import { diffFromPending, pendingPath, takeConflicts, + takeNotice, writeConflicts, writePending, } from './pending.ts'; import { CONFLICTS_DONE, findConflictsToken, + findNoticeToken, findPreviewId, isConflictsCommand, + isNoticeCommand, isPreviewCommand, isSavedResponse, + NOTICE_DONE, PREVIEW_DONE, SCREEN_SHOWN_CONTEXT, } from './preview-command.ts'; @@ -26,9 +30,11 @@ import { buildT1NewSaveInput, buildT1SaveInput } from './save-input.ts'; import { COLS, conflictsScreen, + noticeScreen, previewScreen, savedScreen, type T1ConflictsInput, + type T1NoticeInput, } from './screens.ts'; import { textWidth, wrap } from './text.ts'; import { pickTargets } from './issues.ts'; @@ -811,3 +817,103 @@ describe('hook-saved.ts', () => { expect(runHook('test.t1.no-pending', saved('test.t1.no-pending'))).toBe(''); }); }); + +describe('noticeScreen (예외 안내, 10/14 확정)', () => { + const existing = { + id: 'app.notify.title-length', + title: '앱 알림 제목은 32자 이하로 쓴다', + value: '공백 포함 32자 이하', + }; + const lines = (input: T1NoticeInput) => + stripAnsi(noticeScreen(input)).split('\n'); + + it('예외 3 조사 실패: 경우 1은 작업을, 경우 2는 등록을 멈췄다고 빨간 막대로 알려요', () => { + expect(lines({ kind: 'searchFailed', trigger: 'agent' })).toEqual([ + ' 다른 결정 사항을 조회하지 못해 하던 작업을 멈췄어요.', + ' 잠시 뒤 다시 시도해 보시고 반복된다면 담당자에게 알려 주세요.', + ]); + expect(lines({ kind: 'searchFailed', trigger: 'direct' })[0]).toBe( + ' 다른 결정 사항을 조회하지 못해 등록을 멈췄어요.', + ); + expect( + noticeScreen({ kind: 'searchFailed', trigger: 'agent' }).startsWith( + '\u001b[0;7;31m', + ), + ).toBe(true); + }); + + it('충돌 3 완전히 같음은 빨간 막대, 뜻이 같음은 노란 막대와 기존 결정 사항 줄이에요', () => { + expect(lines({ kind: 'same', existing })).toEqual([ + ' 같은 결정 사항이 있어서 등록하지 않았어요.', + ' [app] 앱 알림 제목은 32자 이하로 쓴다 · 공백 포함 32자 이하', + ]); + expect(lines({ kind: 'similar', existing })[0]).toBe( + ' 같은 뜻의 결정 사항이 이미 있어요.', + ); + expect( + noticeScreen({ kind: 'similar', existing }).startsWith('\u001b[0;7;33m'), + ).toBe(true); + }); + + it('뜻이 같음 → 아니요는 기본 글자색 막대, 예외 7은 노란 막대와 노란 줄이에요', () => { + expect(noticeScreen({ kind: 'similarDeclined' })).toContain( + '\u001b[0;7;39m', + ); + expect(lines({ kind: 'saveRace' })).toEqual([ + ' 저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿨어요.', + ' 바뀐 내용을 다시 확인할게요.', + ]); + }); + + it('뜻이 같음 질문은 예/아니요예요 (제안 문구)', () => { + expect(T1_COPY.similarAsk.question).toBe( + '그래도 새 결정 사항으로 등록할까요?', + ); + expect([T1_COPY.similarAsk.yes.label, T1_COPY.similarAsk.no.label]).toEqual( + ['예', '아니요'], + ); + }); +}); + +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: 'saveRace' })); + 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' }, + { kind: 'searchFailed' }, + { kind: 'same', existing: { id: 'app.notify.title-length' } }, + ]) { + 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/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" "b/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" index 19c2778..98bf9d4 100644 --- "a/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" +++ "b/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" @@ -397,7 +397,7 @@ try { | `packages/core` | `node/env.ts`, `logger.ts`, `fake-db.ts` | `Env`, `Logger`, `FakeDb`, `FakeRpc` | | `packages/notify` | `types.ts`와 각 함수 파일 | 민수님 패키지 자체 타입 (5절 "알리기 패키지의 타입") | | `apps/mcp-server` | `lib/define-tool.ts`, `auth.ts`, `http.ts` | `ToolContext`, `ToolDefinition`, `ToolGroup`, `TokenEntry`, `AuthResult`, `HttpAppOptions`, `HttpApp` | -| `apps/detect` | `src/t1/screens.ts`, `issues.ts` | `T1Conflict`, `T1ConflictsInput` (직접 요청 다른 결정 사항 화면), `T1Target` (③ 받는 이슈, SIL-94) | +| `apps/detect` | `src/t1/screens.ts`, `issues.ts` | `T1Conflict`, `T1ConflictsInput` (다른 결정 사항 화면), `T1NoticeInput` (예외 안내), `T1Target` (③ 받는 이슈, SIL-66·94) | | `apps/detect` | `src/pr/*.ts` | `PullRequestInput`, `PullRequestState`, `GitHubClient`, `Judge`, `PrPathDeps`, `PrPathOptions`, `PrPathOutcome`, `AskReason`, `PeopleMap`, `RenderedResult`, `JudgeUsage`, `ClaudeJudgeOptions` | | `apps/cli` | `export/metrics.ts`, `export/records.ts` | `MetricInput`, `MetricSummary`, `ResponseRow` | | `apps/web` | `src/api/types.ts` | `AppInfo`, `SilApi`, `MyChange` 등 (화면 ↔ 데이터 약속, 11절) | From eb0ed1e0be0f9533793b13f9a7730145ad551be8 Mon Sep 17 00:00:00 2001 From: k0nghaa Date: Thu, 15 Oct 2026 16:32:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(detect):=20T1=20=EC=98=88=EC=99=B8=207?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EC=A0=80=EC=9E=A5=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20hook=EC=9D=B4=20=EA=B1=B0=EC=A0=88=EB=90=9C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EB=9D=84=EC=9B=80=20(SIL-66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로토타입처럼 save_standard가 VERSION_CONFLICT로 거절되면 hook-saved.ts가 노란 막대 "저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿨어요."를 그 자리에서 띄우고, 에이전트에게는 1부터 다시 하라고 전해요. 거절이 PostToolUseFailure로 와도 띄우게 그 때에도 hook을 등록하고, notice.ts는 saveRace를 받지 않아요. Co-Authored-By: Claude Opus 5.5 --- .claude/settings.json | 11 ++++ CLAUDE.md | 2 +- apps/detect/README.md | 16 +++--- apps/detect/src/t1/hook-saved.ts | 24 +++++++- apps/detect/src/t1/notice.ts | 6 +- apps/detect/src/t1/preview-command.ts | 29 ++++++++-- apps/detect/src/t1/t1.test.ts | 80 ++++++++++++++++++++++++++- 7 files changed, 147 insertions(+), 21 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index b070be6..4dfee01 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,6 +31,17 @@ ] } ], + "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", diff --git a/CLAUDE.md b/CLAUDE.md index 820ee2a..fafabc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ - 조사 실패(예외 3): T1 중에 `search_standards`·`get_standard`가 실패하면 "다른 결정 사항 없음"으로 넘어가지 않는다. `{"kind":"searchFailed","trigger":…}`(2와 같은 trigger)를 띄우고 하던 작업이나 등록을 멈춘 뒤 사람의 말을 기다린다. - 완전히 같음(충돌 3): 직접 요청한 값이 그 결정 사항의 지금 값과 같으면(같은 ID·같은 값) `{"kind":"same","existing":{"id","title","value"}}`를 띄우고 선택지 없이 끝낸다. 저장하지 않는다. - 뜻이 같음(충돌 3): 새 결정 사항을 만들어 달라는데 다른 ID의 결정 사항이 같은 뜻이면 `{"kind":"similar","existing":{…}}`를 띄우고 `similarAsk`를 AskUserQuestion으로 묻는다. 예면 새 결정 사항으로 4부터 하고(`isNew`), 아니요면 `{"kind":"similarDeclined"}`를 띄우고 끝낸다. - - 저장하는 사이 바뀜(예외 7): `save_standard`가 `VERSION_CONFLICT`로 거절되면 `{"kind":"saveRace"}`를 띄우고 1부터 다시 한다. `get_standard`로 새 `currentVersion`을 읽어 2의 다른 결정 사항 화면을 다시 띄우고, 사람이 이미 고른 예와 정한 이유는 그대로 두고 5(③)부터 다시 묻는다. + - 저장하는 사이 바뀜(예외 7): `save_standard`가 `VERSION_CONFLICT`로 거절되면 저장 도구 hook이 노란 막대 안내를 바로 띄운다(`notice.ts`를 실행하지 않는다). 1부터 다시 한다. `get_standard`로 새 `currentVersion`을 읽어 2의 다른 결정 사항 화면을 다시 띄우고, 사람이 이미 고른 예와 정한 이유는 그대로 두고 5(③)부터 다시 묻는다. - 어느 경우든 6에서 저장하지 않았으면 결정 사항과 다른 값으로 코드를 쓰지 않는다. - (T1로 저장한 경우는 위 6을 따르고 이 줄은 하지 않는다) 기준을 바꾸기로 하면: 해당 기준의 값·이유·근거를 고치고 버전을 1 올린 뒤, CHANGELOG.md 맨 아래에 한 줄을 남긴다. 형식: 날짜 시각 | 누가 | 기준 ID 이전→새 버전 | 이전 값 → 새 값 | 이유 | 출처(코드/AI 대화/Figma/회의/Slack) diff --git a/apps/detect/README.md b/apps/detect/README.md index 97137a5..5ed5a11 100644 --- a/apps/detect/README.md +++ b/apps/detect/README.md @@ -20,7 +20,7 @@ | `src/t1/screens.ts` | hooks가 띄우는 화면 모양(③ 등록될 모습, 다른 결정 사항 화면). 부건님 프로토타입 "T1 결정 사항 등록"(10/14)을 옮겼어요. 칸 계산은 `text.ts` | | `src/t1/conflicts.ts` | 다른 결정 사항 화면 값을 임시 파일(`haribo-t1-conflicts-.json`)에 들고 있는 명령. hook이 띄운 뒤 바로 지워요 (경우 1·2) | | `src/t1/issues.ts` | ③ 이슈 표에 넣을 받는 이슈를 core `listIssues()`로 팀 DB에서 읽어요(팀원 `.env`의 Supabase 주소·공용 키). 알리기와 같은 규칙(담당자 있음, 바꾼 본인 뺌). 못 읽으면 표 칸 이름만 보여요 | -| `src/t1/notice.ts` | 예외 안내(조사 실패, 완전히 같음, 뜻이 같음, 저장하는 사이 바뀜) 값을 임시 파일(`haribo-t1-notice-.json`)에 들고 있는 명령. hook이 띄운 뒤 바로 지워요 (10/14 확정 예외) | +| `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 오류가 나지 않아요 | @@ -33,13 +33,13 @@ - 기존 결정 사항이 있으면: `conflicts.ts`를 `trigger: "direct"`로 실행(머리표 "요청하신 변경이 기존 결정 사항과 달라요") → 묻기(예/아니요)는 건너뛰고 이유 → ③ → "달라지게 되는 기존 결정 사항 내역을 확인했나요? 이대로 등록할까요?" - 기존 결정 사항이 없으면: `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` 직후 | ③을 거친 저장이 성공하면 ④ 등록 완료(초록 첫 줄, 제목·항상 적용, 보낸 이슈 수)를 띄우고 저장한 결정 사항의 임시 값만 지워요 | +| 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 시험). diff --git a/apps/detect/src/t1/hook-saved.ts b/apps/detect/src/t1/hook-saved.ts index 150b57a..9d0a359 100644 --- a/apps/detect/src/t1/hook-saved.ts +++ b/apps/detect/src/t1/hook-saved.ts @@ -1,17 +1,27 @@ /** - * hook (PostToolUse, save_standard): 저장 직후 ④ 등록 완료를 띄우고 저장한 결정 사항의 ③ 임시 값을 지워요 (SIL-66 T1). + * 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, screenHookOutput } from './preview-command.ts'; -import { savedScreen } from './screens.ts'; +import { + isSavedResponse, + isVersionConflict, + SAVE_RACE_CONTEXT, + screenHookOutput, +} from './preview-command.ts'; +import { noticeScreen, savedScreen } from './screens.ts'; 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); @@ -31,4 +41,12 @@ if (pending && isSavedResponse(event.tool_response)) { ), ), ); +} 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 index 3ce0419..459da50 100644 --- a/apps/detect/src/t1/notice.ts +++ b/apps/detect/src/t1/notice.ts @@ -4,7 +4,8 @@ * node apps/detect/src/t1/notice.ts <<'EOF' * {"kind":"searchFailed","trigger":"agent"} * EOF - * kind: searchFailed(trigger 필요) / same·similar(existing: id·title·value 필요) / similarDeclined / saveRace + * kind: searchFailed(trigger 필요) / same·similar(existing: id·title·value 필요) / similarDeclined. + * 예외 7(저장하는 사이 바뀜)은 저장 도구 hook(`hook-saved.ts`)이 바로 띄워서 여기서 받지 않아요 */ import { readFileSync } from 'node:fs'; import { STANDARD_ID_PATTERN } from '@sil/core'; @@ -33,7 +34,6 @@ function isValid(notice: T1NoticeInput): boolean { isFilled(notice.existing.value) ); case 'similarDeclined': - case 'saveRace': return true; default: return false; @@ -42,7 +42,7 @@ function isValid(notice: T1NoticeInput): boolean { if (!isValid(input)) { console.error( - 'kind(searchFailed|same|similar|similarDeclined|saveRace)와 그 kind에 필요한 칸(trigger 또는 existing의 id·title·value)이 있어야 해요', + 'kind(searchFailed|same|similar|similarDeclined)와 그 kind에 필요한 칸(trigger 또는 existing의 id·title·value)이 있어야 해요', ); process.exit(1); } diff --git a/apps/detect/src/t1/preview-command.ts b/apps/detect/src/t1/preview-command.ts index dbf4889..b66cbc0 100644 --- a/apps/detect/src/t1/preview-command.ts +++ b/apps/detect/src/t1/preview-command.ts @@ -20,18 +20,39 @@ export const NOTICE_DONE = 'T1 안내 값을 임시로 들고 있어요:'; export const SCREEN_SHOWN_CONTEXT = 'T1 화면을 hook이 사람에게 이미 보여 줬어요. 화면을 띄웠다거나 무엇을 보여 줬는지 설명하는 글을 쓰지 말고, CLAUDE.md T1의 다음 단계로 바로 가세요.'; -/** hook 출력: 사람에게 보이는 화면과 에이전트에게만 가는 말 */ -export function screenHookOutput(screen: string): string { +/** 예외 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: 'PostToolUse', - additionalContext: SCREEN_SHOWN_CONTEXT, + 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( diff --git a/apps/detect/src/t1/t1.test.ts b/apps/detect/src/t1/t1.test.ts index 6e7440f..54e6808 100644 --- a/apps/detect/src/t1/t1.test.ts +++ b/apps/detect/src/t1/t1.test.ts @@ -22,8 +22,10 @@ import { isNoticeCommand, isPreviewCommand, isSavedResponse, + isVersionConflict, NOTICE_DONE, PREVIEW_DONE, + SAVE_RACE_CONTEXT, SCREEN_SHOWN_CONTEXT, } from './preview-command.ts'; import { buildT1NewSaveInput, buildT1SaveInput } from './save-input.ts'; @@ -760,6 +762,20 @@ describe('isSavedResponse', () => { }); }); +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( @@ -813,6 +829,61 @@ describe('hook-saved.ts', () => { 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(''); }); @@ -884,7 +955,10 @@ describe('notice.ts와 hook-notice.ts', () => { ); it('명령이 값을 들고, 직후 hook이 안내를 띄우고 임시 값을 지워요', () => { - const stdout = runNode('./notice.ts', JSON.stringify({ kind: 'saveRace' })); + const stdout = runNode( + './notice.ts', + JSON.stringify({ kind: 'similarDeclined' }), + ); const token = findNoticeToken(stdout); const output = JSON.parse( runNode( @@ -896,7 +970,7 @@ describe('notice.ts와 hook-notice.ts', () => { ), ) as { systemMessage: string }; expect(stripAnsi(output.systemMessage)).toContain( - '저장하는 사이 다른 사람이 먼저 이 결정 사항을 바꿨어요.', + '등록하지 않았어요. 기존 결정 사항을 그대로 둘게요.', ); expect(takeNotice(token)).toBeNull(); }); @@ -904,6 +978,8 @@ describe('notice.ts와 hook-notice.ts', () => { it('kind나 필요한 칸이 없으면 거절해요', () => { for (const bad of [ { kind: 'x' }, + // 예외 7은 저장 도구 hook이 띄워요 + { kind: 'saveRace' }, { kind: 'searchFailed' }, { kind: 'same', existing: { id: 'app.notify.title-length' } }, ]) {