Engineering workflow commands for Claude Code

Engineering workflow commands for Claude Code.
<!-- SKILLS_TABLE_START -->
| Command | Description | When to use |
|---|---|---|
/cc-cmds:autopilot | 목표 하나를 받아 이 세션이 라우터가 되어 스킬 호출을 스스로 정하며 완주시키는 파이프라인의 킥오프와 아침 보고 | 사용자가 설계 문서·레포·PR·브랜치, 또는 아직 산출물이 없는 목표를 던져 두고 설계·감사·구현·리뷰·머지·적용까지 알아서 이어지게 하고 싶을 때 — 진행은 이 터미널로 중계되고 중요한 결정만 물어 온다. 또는 그렇게 돌린 런의 아침 보고를 받을 때 |
/cc-cmds:autopilot-router-shift | 게이트가 띄운 헤드리스 라우터 샤드가 받는 라우팅 루프 — 스냅숏을 읽어 한 행위를 정하고 게이트에 넘기며, 상한·승인·종단·중단에서 인수인계 행을 남기고 끝난다 | 이 커맨드는 사람이 치는 것이 아니다 — 라우터 샤드가 받는 스킬이다. 리드 세션이 gate.sh act --kind router-shift 로 교대를 시작할 때 그 샤드가 이 문서를 프롬프트로 받는다 |
/cc-cmds:design | 에이전트 팀을 활용한 기능 설계 토론 진행 | 사용자가 새 기능 설계/아키텍처 결정/다관점 검토가 필요한 설계 논의를 요청할 때 |
/cc-cmds:design-analyze | 에이전트 팀을 활용한 제3자 설계 문서 다관점 분석 (읽기 전용) | 타인이 작성한 설계/리팩토링 문서를 원본 수정 없이 다관점으로 분석하고 분석 산출물(보고서/주석본/피드백)을 생성하고자 할 때 |
/cc-cmds:design-apply | Claude Design (claude.ai/design) 산출물을 타깃 코드베이스에 통합하는 구현 상세 설계를 agent team으로 작성 | design-ingest가 ACCEPT한 핸드오프 추출본을 기반으로 실제 코드베이스에 적용할 구현 상세 설계(impl-design.md)가 필요할 때 |
/cc-cmds:design-audit | 동결된 설계 문서를 독립 리더 팬아웃으로 1회 감사하고 정합 조정 1회 후 정지 (반복 루프 없음) | 설계 문서 작성이 끝나 더 이상 수정하지 않을 시점에, 문서를 동결한 뒤 레포 실측 기반 독립 감사로 잔여 결함을 드러내고 이름 붙은 하류 소유자에게 인계하고자 할 때 (design 종단 이후 · design-apply의 impl-design.md · implement 직전) |
/cc-cmds:design-audit-unattended | 동결된 설계 문서를 독립 리더 팬아웃으로 1회 감사하고 정합 조정 1회 후 정지 (무인 — 사람 확인 없이 park) | 자율 파이프라인 드라이버가 감사 스테이지를 헤드리스로 디스패치할 때. 사람이 직접 부르는 경우에는 /cc-cmds:design-audit를 쓸 것 |
/cc-cmds:design-base | 여러 티켓으로 나눌 큰 작업의 베이스 설계 — 티켓 간 계약과 병렬화 최대의 티켓 분할을 설계하고, 감사 뒤 트래커 발행까지 | 요청이 각자 독자적 설계 판단이 필요한 조각들로 나뉘는 큰 작업이라, 단일 설계 문서 하나로 구현 슬라이스를 나누기보다 먼저 조각 간 계약과 티켓 분할을 정하고 조각마다 따로 설계하려 할 때 |
/cc-cmds:design-base-unattended | 베이스 설계의 무인 팔 — 좌석·드라이버가 파견하면 베이스 문서의 토론·저장(드라이버면 동결까지)을, --split 이면 감사 뒤 분할 점검과 트래커 발행을 돈다 (질문은 park·정지 기록으로) | 사람이 직접 부르지 않는다. design-base 리드 좌석이 Step 2 승인 뒤 claude -p 로 파견하거나(다리), autopilot 이 베이스 런의 설계 단계와 분할 단계로 파견한다(스테이지) |
/cc-cmds:design-discuss-unattended | 설계 세션의 무인 팔 — 좌석이 파견하면 Step 3 토론과 Step 4 종합·저장을 도는 다리, autopilot 드라이버가 파견하면 Step 7U 동결까지 도는 스테이지 (질문은 park·정지 기록으로) | 사람이 직접 부르지 않는다. design 리드 좌석이 Step 2 승인 뒤 claude -p 로 파견하거나(다리), autopilot 드라이버가 design_required 인 런의 첫 스테이지로 파견한다(스테이지) |
/cc-cmds:design-ingest | Claude Design (claude.ai/design) 핸드오프 번들을 파싱·리뷰하고 ACCEPT/REFINE 판정으로 개선 루프 진행 | claude.ai/design 에서 받은 HTML 핸드오프 번들을 검토·수용·재프롬프트할 때 (단일 호출 또는 외부 재실행 사이 반복) |
/cc-cmds:design-lite | 2인 팀을 활용한 경량 설계 토론 | 깊은 다관점 분석보다 빠른 방향 설정이 우선될 때 (sonnet 단독 합성으로 미묘한 invariant 누락 가능) |
/cc-cmds:design-prompt | Claude Design (claude.ai/design) 실행용 프롬프트+컨텍스트를 base 설계 문서에 authoring하고 붙여넣기 블록 emit (standalone + idempotent, HANDOFF CONTRACT 포함) | base 설계 작성 후, claude.ai/design 에 보낼 의도 중심 프롬프트와 DS 참조를 base 설계 문서에 추가하거나 리뷰 반영본으로 붙여넣기 블록을 재조립할 때 |
/cc-cmds:design-reconverge | 반증된 검증 항목이나 설계 결함 발견 하나에 스코프된 재수렴 — 설계를 고치고 두 값 판정 후 정지 (무인) | 자율 파이프라인 드라이버가 사다리 R2(재설계) 레인에 진입할 때, 라우터 교대가 구현 단계의 잔여 항목 반증(implement-unattended Step 1.5d 또는 Step 3 구현 중 반증 중단)을 재수렴으로 파견할 때, 사람이 감사 종합 질문의 후보 요구를 채택해 라우터 교대가 재수렴으로 넘길 때, 또는 구현 단계의 구속 티어 이탈 중단(implement-unattended CFI-U3)에 사람이 재수렴 을 골라(또는 매니페스트의 구속-이탈 자동 채택 행으로) 라우터 교대가 넘길 때. 사람이 참여하는 재설계는 /cc-cmds:design으로 처음부터 다시 수렴할 것 |
/cc-cmds:design-system | Claude Design (claude.ai/design) DS 생성 프롬프트 emit + DS 번들 ingest로 docs/design-system/ 워크스페이스 구축 (2-phase) | FE 파이프라인 시작 전 프로젝트 전역 design system을 claude.ai/design으로 생성·도입할 때 (1회성 또는 재ingest) |
/cc-cmds:design-upgrade | 팀 구성 강화 분석 (모델·역할 축) | 직전 /design 팀 구성 제안에서 opus 승격이 유의미한 역할이 있는지, 또는 누락 도메인을 메울 신규 역할·과부하 역할 분할이 필요한지 second-opinion으로 검토할 때 |
/cc-cmds:implement | 설계 문서 기반 구현 | 사용자가 작성된 설계 문서를 바탕으로 단계적 계획을 세우고 실제 구현을 수행하기를 원할 때 |
/cc-cmds:implement-unattended | 설계 문서 기반 구현 (무인 — 사람 확인 없이 오케스트레이터가 라우팅) | 자율 파이프라인 드라이버가 세그먼트 구현 스테이지를 헤드리스로 디스패치할 때. 사람이 직접 부르는 경우에는 /cc-cmds:implement를 쓸 것 |
/cc-cmds:review | 에이전트 팀을 활용한 다관점 코드 리뷰 | 사용자가 PR/로컬 diff/파일 경로에 대한 다관점 코드 리뷰(보안/성능/품질 등)를 요청할 때 |
/cc-cmds:review-lite | 2인 팀을 활용한 경량 코드 리뷰 | 빠른 코드 리뷰가 목적이고 다관점 심층 분석이 불필요할 때 (큰 PR coverage gap, 미묘한 race condition·authn bypass 검출률 약화 가능) |
/cc-cmds:review-unattended | 에이전트 팀을 활용한 다관점 코드 리뷰 (무인 — 사람 확인 없이 리포트까지 완주) | 자율 파이프라인 드라이버가 리뷰 스테이지를 헤드리스로 디스패치할 때. 사람이 직접 부르는 경우에는 /cc-cmds:review를 쓸 것 |
/cc-cmds:review-upgrade | 리뷰어 구성 강화 분석 (모델·역할 축) | 직전 /review Step 3 리뷰어 구성 제안에서 opus 승격이 유의미한 역할이 있는지, 누락된 리뷰 관점을 메울 신규 리뷰어 추가가 필요한지, 또는 과부하 리뷰어 분할이 필요한지 second-opinion으로 검토할 때 |
<!-- SKILLS_TABLE_END -->
/cc-cmds:design, /cc-cmds:design-audit 등 에이전트 팀 기반 커맨드는 Claude Code 2.1.178 이상이 필요합니다. 팀원은 nameless background task(Agent 도구)로 구동되므로 별도 환경변수 설정은 필요 없습니다(이전에 안내하던 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS는 더 이상 요구되지 않습니다).
1단계 — 설치 (필수 선결 조건):
brew install terminal-notifier
brew install jq # PreToolUse hook 의존성
2단계 — 자연어 요청:
사용 예시 (대화 중 이렇게 말하면 됩니다):
| 이렇게 말하세요 | 동작 |
|---|---|
"npm run build 끝나면 알림 줘" | 단발 — 완료 시 1회 |
"배포 완료되면 알려줘" | 단발 — 완료 시 1회 |
"PR 리뷰 시작할 때랑 끝날 때 알림 줘" | 단발 (2회) — 각 시점 발송 |
"lint 끝날 때, 빌드 끝날 때, 배포 완료 시 알림 줘" | 단발 (3회) — 각 시점 발송 |
"작업이 70% 정도 끝나면 알림 줘" | 단발 — 모델 추정 시점 1회 (근사값) |
"매 단계마다 알림 줘" | 반복 — 매 turn 발송; "알림 취소"로 중단 |
"알림 취소" | 반복·단발 모두 취소 |
※ 진행률·중간 지점 표현 주의 — "70% 정도 끝나면", "중간쯤 되면"처럼 백분율이나 중간 지점을 지정하면, 모델이 작업을 진행하며 스스로 추정한 시점에 알림을 1회 보냅니다. 진행률 측정기나 타이머가 따로 동작하는 것이 아니므로 실제 발송 시점은 모델의 주관적 판단에 따른 근사값입니다.
3단계 — 최초 macOS 권한 승인 (1단계 완료 후): 첫 알림 시 macOS 권한 다이얼로그가 표시됩니다. 미리 트리거하려면 "알림 테스트 한 번 해줘"로 발화하여 테스트 알림을 받고 허용을 클릭하세요. (Claude Code의 Bash 권한 다이얼로그는 플러그인의 PreToolUse hook이 자동 승인하므로 표시되지 않습니다 — macOS 알림 권한 다이얼로그만 1회 응답하면 됩니다.) 다이얼로그를 놓쳤다면 시스템 설정 → 알림 → terminal-notifier에서 수동 활성화. 권한 거부 후 복구는 셸에서 terminal-notifier -message 'cc-cmds permission test' -title '[cc-cmds] test' -group cc-cmds-active-notify -execute ':' 직접 실행으로 재트리거 (스킬 bypass와 동일 형식이라 banner 외관이 일치).
배너 클릭 — active-notify 배너, autopilot 런 배너, 일반 세션 훅 배너를 클릭하면 그 배너를 띄운 세션의 iTerm2 창·탭으로 이동하고 tmux window·pane까지 선택합니다. iTerm2 tmux 통합(tmux -CC)에서도, iTerm2 탭 안의 일반 tmux에서도 동작합니다. iTerm2가 아닌 터미널, tmux 밖의 세션, 이미 닫힌 pane에서는 클릭해도 아무 일도 일어나지 않습니다. 처음 클릭할 때 macOS가 iTerm2 제어(자동화)를 허용할지 한 번 묻고, 거부하면 클릭은 아무 일도 하지 않습니다. 이 선택은 시스템 설정 → 개인 정보 보호 및 보안 → 자동화에서 되돌릴 수 있습니다.
terminal-notifier가 없거나 macOS가 아니면 알림은 오류 없이 비활성화됩니다.
/cc-cmds:design-ingest의 단일 에이전트 리뷰 단계는 환경에 web-design-guidelines 스킬이 설치되어 있으면 UI/접근성/반응형 평가를 그 스킬로 보강합니다. 이 스킬은 vercel-labs agent-skills 리포의 skills-CLI 개별 스킬이며 Claude Code 마켓플레이스 플러그인이 아닙니다 — 따라서 cc-cmds plugin.json의 dependencies로는 표현이 불가능하고, 다음 명령으로 ~/.claude/skills/에 직접 설치합니다.
npx skills add https://github.com/vercel-labs/agent-skills --skill web-design-guidelines
부재 시 design-ingest는 자체 5축 기준(토큰-vs-DS 일치도, a11y 대비비 산술, 반응형/터치 영역 44px+, base 의도 충실도, 시각 품질)으로 fallback하며 리뷰는 중단되지 않습니다. terminal-notifier와 동일 doctrine — plugin.json dependencies로 표현 불가한 선택적 외부 의존성(skills-CLI 개별 스킬·brew 도구 등)은 graceful degradation + README 권장-설치-명령 패턴을 따릅니다.
# 1. 마켓플레이스 등록
/plugin marketplace add Nharu/cc-cmds
# 2. 플러그인 설치
/plugin install cc-cmds@cc-cmds
/cc-cmds:design <task>
/cc-cmds:design-lite <task>
/cc-cmds:design-audit <design-doc-path> [<note>] [--base]
/cc-cmds:design-upgrade
/cc-cmds:implement <design-doc-path>
/cc-cmds:review [<target>] [<directive>]
/cc-cmds:review-lite [<target>]
각 커맨드의 옵션·입력 형태 세부는 아래 Options 섹션 참조.
대화형 세션에서는 상태 표시줄이 고른 autopilot 런의 상태를 autopilot 패널로 보여 주며(살아 있는 런마다 한 번 저절로 열림), /autopilot-status 로 그 패널을 열고 닫는다. 패널은 전체 화면 세션에서 110칸 이상이라 도킹될 때만 보이고, 그 밖의 화면에서는 명령이 안내 한 줄만 띄운다 — 동작 규약과 전체 화면을 켜는 법은 plugins/cc-cmds/hooks/README.md 참조.
/plugin update cc-cmds
/plugin uninstall cc-cmds
<!-- SKILLS_OPTIONS_START -->
Usage: /cc-cmds:autopilot <의도 또는 대상> [--report]
| Option | Default | Summary |
|---|---|---|
<의도 또는 대상> | (required) | 이 런이 무엇에 관한 것인지 — 설계 문서 경로(.md), 레포 슬러그, PR·브랜치 참조, 또는 아직 산출물이 없는 자유 텍스트 의도. 앵커 종류는 1막의 진입 판정이 정한다. |
--report | off (킥오프 모드 — 1막 인터뷰 후 드라이버 기동) | 아침 보고 모드. 그 런의 매니페스트·원장·보고서, 설계 문서, 그리고 매니페스트나 run 행이 없을 때 킥오프 흔적을 읽어 한국어로 렌더링만 하고, 새 런을 시작하지 않는다. |
Parsing (
<의도 또는 대상>):$ARGUMENTS전체를 의도로 읽는다..md토큰이 있으면 문서 앵커 후보로 우선 해석하되, 최종 앵커 종류는 진입 판정과 사용자 확인이 정한다.
Usage: (사람이 치는 커맨드가 아니다 — gate.sh act --kind router-shift 가 claude -p 로 넘긴다)
autopilot 의 Act 2b 를 대신 도는 헤드리스 좌석이다. 사람에게 묻는 자리도, 배너를 띄우는 자리도, 진행 채널을 여는 자리도 아니다 — 그 셋은 전부 리드에 남는다. 이 샤드가 하는 것은 스냅숏을 읽고 한 행위를 정해 게이트에 넘기는 것뿐이며, 끝날 때 후임이 읽을 인수인계 행 하나를 남긴다.
Usage: /cc-cmds:design <task>
| Option | Default | Summary |
|---|---|---|
<task> | (required) | 설계 토론을 진행할 작업 주제 (자유형 한국어/영문 텍스트). |
Usage: /cc-cmds:design-analyze <design-doc-path> [--no-codebase] [--report-only]
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 분석 대상 제3자 설계 문서 경로 (.md). 원본은 절대 수정하지 않음. |
--no-codebase | off (코드베이스 grounding 활성) | 코드베이스 교차검증 비활성화 — 문서 자체만으로 분석 (doc-only 모드). |
--report-only | off (산출물 대화형 선택) | Step 7 산출물 선택 대화만 건너뛰고 보고서만 생성. Step 6 워크스루(발견별 검토)는 그대로 유지 — 완전 비대화 아님(산출물 범위 한정 플래그). |
Usage: /cc-cmds:design-apply <handoff-extract-path>
| Option | Default | Summary |
|---|---|---|
<handoff-extract-path> | (required) | design-ingest가 확정한 안정 사본 (docs/{slug}-fe/handoff-extract.md); 본 스킬이 slug 파싱·출력 경로·원장 키의 단일 앵커 |
Usage: /cc-cmds:design-audit <design-doc-path> [<note>] [--base]
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 감사 대상 설계 문서 경로 (.md). 첫 리더 spawn 직전의 sha256으로 동결되며, 감사가 끝날 때까지 어떤 바이트도 수정되지 않는다. |
<note> | (optional) | 문서 경로 뒤 자유 텍스트. 전 리더에게 축어로 동일하게 주입되는 초점 메모 (리더별로 다르게 주면 보강 통계가 무의미해지므로 금지). |
--base | off | base 설계 문서 모드 — 기존 내용의 정합·완결만 감사하고 신규 구현 세부 제안을 금지한다. FE 파이프라인이 확장한 base 문서의 호출 형태. |
Usage: /cc-cmds:design-audit-unattended <design-doc-path> [<note>] [--base]
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 감사 대상 설계 문서 경로 (.md). 드라이버가 메인 워크트리 절대 경로로 넘긴다. 첫 리더 spawn 직전의 sha256으로 동결된다. |
<note> | (optional) | 문서 경로 뒤 자유 텍스트. 전 리더에게 축어로 동일하게 주입되는 초점 메모. |
--base | off | base 설계 문서 모드 — 기존 내용의 정합·완결만 감사하고 신규 구현 세부 제안을 금지한다. |
Usage: /cc-cmds:design-base <task> | --split <문서>
| Option | Default | Summary |
|---|---|---|
<task> | (required) | 베이스 설계를 진행할 작업 주제 (자유형 한국어/영문 텍스트). --split 모드에서는 쓰지 않는다. |
--split <문서> | off | 분할 모드 — 동결·감사를 마친 베이스 설계 문서를 다시 점검하고, 고른 트래커에 베이스 티켓과 각 티켓을 발행하거나 문서만으로 분할을 기록한다. |
Parsing (
<task>):$ARGUMENTS에--split토큰이 없으면 전체가 작업 주제다.
Parsing (
--split <문서>):--split다음의 첫.md토큰이 베이스 문서 경로다. 있으면 설계 흐름(Step 1–7)은 돌지 않는다.
Usage: /cc-cmds:design-base-unattended <brief-or-doc-path> [<task-sentence>] | --split <문서>
| Option | Default | Summary |
|---|---|---|
<brief-or-doc-path> | (required) | 좌석 파견: docs/design-brief/{slug}.md — design-base 좌석이 쓴 인터뷰 브리프. 드라이버 파견: 베이스 설계 문서 경로(메인 워크트리 절대 경로 — 아직 없을 수 있다). |
<task-sentence> | (optional) | 드라이버 파견에서만 — 매니페스트 ## 의도 의 첫 줄. |
--split <문서> | off | 분할 단계 — 감사를 마친 베이스 문서를 다시 점검하고 매니페스트 베이스 발행 행대로 트래커에 발행하거나 문서만으로 기록한다. 드라이버 파견에서만 쓴다. |
_Parsing (
<brief-or-doc-path>):$ARGUMENTS의 첫.md토큰. 어느 파견인지는CC_PIPELINE_RUN_ID의 유무로 가른다._
Parsing (
<task-sentence>): 첫.md토큰 이후의 모든 내용. 좌석 파견과--split에서는 무시한다.
Parsing (
--split <문서>):--split다음의 첫.md토큰이 베이스 문서 경로(메인 워크트리 절대 경로)다.
Usage: /cc-cmds:design-discuss-unattended <brief-or-doc-path> [<task-sentence>]
| Option | Default | Summary |
|---|---|---|
<brief-or-doc-path> | (required) | 좌석 파견: docs/design-brief/{slug}.md — 좌석이 쓴 인터뷰 브리프. 드라이버 파견: 설계 문서 경로(메인 워크트리 절대 경로, 드라이버가 준다 — 아직 없을 수 있다). |
<task-sentence> | (optional) | 드라이버 파견에서만 — 매니페스트 ## 의도 의 첫 줄. 인터뷰 브리프가 없을 때 과제 문면이 되는 한 문장. |
_Parsing (
<brief-or-doc-path>):$ARGUMENTS의 첫.md토큰. 어느 파견인지는 이 인자가 아니라CC_PIPELINE_RUN_ID의 유무로 가른다._
Parsing (
<task-sentence>): 첫.md토큰 이후의 모든 내용. 좌석 파견에서는 무시한다.
Usage: /cc-cmds:design-ingest <handoff-dir-path>
| Option | Default | Summary |
|---|---|---|
<handoff-dir-path> | (required) | 기능 핸드오프 디렉토리 (docs/{slug}-fe/handoff); incoming/ 하위 번들을 소비 |
Usage: /cc-cmds:design-lite <task>
| Option | Default | Summary |
|---|---|---|
<task> | (required) | 설계 토론을 진행할 작업 주제 (자유형 한국어/영문 텍스트). |
Usage: /cc-cmds:design-prompt <base-doc-path>
| Option | Default | Summary |
|---|---|---|
<base-doc-path> | (required) | base 설계 문서 경로 (docs/{slug}.md); 본 스킬이 그 안에 CD 프롬프트 섹션을 in-place authoring |
Usage: /cc-cmds:design-reconverge <design-doc-path> <scope>
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 재수렴 대상 설계 문서 경로 (.md). 드라이버와 라우터 교대가 메인 워크트리 절대 경로로 넘긴다. |
<scope> | (required) | 재수렴 스코프. R<n> 형태의 잔여 검증 항목 식별자, (정규화 파일 경로, 카테고리 태그) 형태의 문제 동일성, 감사 종합 요구 <중단 기록 절대 경로> 형태의 채택된 감사 종합 요구, 또는 구속 티어 이탈 <중단 기록 절대 경로> 형태의 사람이 재수렴을 고른 구속 티어 이탈. |
Parsing (
<design-doc-path>):$ARGUMENTS의 첫.md토큰을 경로로 해석.
Parsing (
<scope>): 첫.md토큰 이후의 모든 내용. 비어 있으면 중단 기록을 남기고 정지 — 스코프 없는 재설계는 이 스킬이 하는 일이 아니다.
Usage: /cc-cmds:design-system [<intent>]
| Option | Default | Summary |
|---|---|---|
[<intent>] | (optional) | DS 생성 의도/스코프 서술용 자유형 토큰 (생략 시 base 설계·코드베이스에서 추론) |
Usage: /cc-cmds:design-upgrade
이 커맨드는 별도 인자를 받지 않으며, 직전 /design 팀 구성 제안이 현재 대화 컨텍스트에 있어야 동작한다. 모델 승격과 역할 추가·분할은 강화가 유의미할 때만 제안하며, 그 외에는 유지 사유를 제시한다. 독립 실행 시 결과가 불정확할 수 있다.
Usage: /cc-cmds:implement <design-doc-path> [scope-directive]
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 구현 대상 설계 문서 경로 (.md). |
[scope-directive] | (optional) | 구현 범위를 좁히는 자유형 자연어 지시문 (예: "Phase 2", "PR #0"). |
Parsing (
<design-doc-path>):$ARGUMENTS의 첫.md토큰을 경로로 해석. 이후 토큰은 scope directive로 전달.
Parsing (
[scope-directive]): 첫.md토큰 이후의 모든 내용. 단일 바깥쪽 쌍따옴표로 감싸져 있으면 그 쌍만 제거하고 안쪽 따옴표·구두점은 보존.
Usage: /cc-cmds:implement-unattended <design-doc-path> [scope-directive]
| Option | Default | Summary |
|---|---|---|
<design-doc-path> | (required) | 구현 대상 설계 문서 경로 (.md). 드라이버가 메인 워크트리 절대 경로로 넘긴다. |
[scope-directive] | (optional) | 구현 범위를 좁히는 자유형 자연어 지시문. 드라이버가 세그먼트 범위를, 라우터의 수정 재파견이 리뷰 리포트의 수정 대상을 이 자리에 싣는다. |
Parsing (
<design-doc-path>):$ARGUMENTS의 첫.md토큰을 경로로 해석. 이후 토큰은 scope directive로 전달.
Parsing (
[scope-directive]): 첫.md토큰 이후의 모든 내용. 단일 바깥쪽 쌍따옴표로 감싸져 있으면 그 쌍만 제거하고 안쪽 따옴표·구두점은 보존.
Usage: /cc-cmds:review [<target>] [--base-sha <sha>] [--declared-files <csv>] [<directive>]
| Option | Default | Summary |
|---|---|---|
<target> | (optional) | 리뷰 대상. 입력 형태에 따라 PR/브랜치/파일 모드로 자동 분기. |
<directive> | (optional) | 리뷰 관점 지시문. <target> 뒤에 자연어로 부가 (예: "보안 중심으로"). |
--base-sha <sha> | off (gh pr view … baseRefName 또는 기본 브랜치에서 base 를 스스로 유도) | diff 의 base 를 호출자가 지정. 이미 base 를 아는 호출자(파이프라인 드라이버 등)가 리뷰의 재유도를 없애기 위해 넘긴다. 넘겨받은 값은 신뢰하지 않고 git merge-base --is-ancestor 로 검증하며, 검증에 실패하면 기존 유도로 폴백하고 그 사실을 리포트 개요에 남긴다. |
--declared-files <csv> | off (변경 파일 집합을 diff 에서만 유도) | 이 변경이 건드리기로 선언된 파일 집합(쉼표 구분). diff 는 무엇이 바뀌었는지만 말하고 무엇이 바뀌기로 되어 있었는지는 말하지 않으므로, 선언 밖 파일이 리뷰 범위 안에 있을 때 그것을 지목할 수 있게 한다. |
<target> 입력 형태별 처리:
https://github.com/owner/repo/pull/42 → PR 번호 추출 후 gh pr view로 메타데이터 수집42 → 숫자만일 때 PR 번호로 해석feat/auth-flow → 하이픈·영문 포함 시 브랜치로 해석, gh pr list --head로 연관 PR 조회src/auth/ → 파일 리뷰 모드; gh 명령 사용 안 함PR #42 보안 중심으로 → 타겟 추출 후 지시문을 팀 구성·컨텍스트 패키지·보고서에 전파. 지시문은 깊이/커버리지에만 영향; severity는 기술 기준으로 독립 평가.Parsing (
<target>): 숫자만 포함된 토큰(42)은 PR 번호, 하이픈·영문 포함 토큰(42-fix-bug)은 브랜치로 해석. 순수 숫자 + 브랜치 동시 존재 시 PR 번호 우선. 어느 형태에도 해당되지 않으면AskUserQuestion으로 명확화.
Parsing (
<directive>): 지시문은 severity 기준을 변경하지 않음 — 리뷰 팀 구성과 컨텍스트 가중치에만 영향. 인식된 플래그와 그 값을 뺀 나머지가 지시문이며, 인식되지 않는--토큰은 지시문으로 흡수하지 않고 경고 후 폐기한다.
Parsing (
--base-sha <sha>):--base-sha다음 토큰을 값으로 취한다. 값이 없으면 플래그를 무시하고 기존 유도를 쓴다. 이 토큰과 값은<directive>추출 전에 인자열에서 제거된다.
Parsing (
--declared-files <csv>):--declared-files다음 토큰을 값으로 취한다. 쉼표·공백을 포함할 수 있으므로 인용 부호로 감싸 넘긴다. 이 토큰과 값은<directive>추출 전에 인자열에서 제거된다.
Usage: /cc-cmds:review-lite [<target>] [--base-sha <sha>] [--declared-files <csv>]
| Option | Default | Summary |
|---|---|---|
<target> | (optional) | 리뷰 대상 (PR 번호/URL, 브랜치, 파일/디렉토리, 또는 생략 시 현재 브랜치 자동 감지). PR 크기 무관 — 큰 PR 은 report 의 리뷰 범위 섹션에 미커버 영역 명시. |
--base-sha <sha> | off (gh pr view … baseRefName 또는 기본 브랜치에서 base 를 스스로 유도) | diff 의 base 를 호출자가 지정. 넘겨받은 값은 git merge-base --is-ancestor 로 검증하며, 실패하면 기존 유도로 폴백하고 그 사실을 리포트 개요에 남긴다. lite 에서도 동일하다. |
--declared-files <csv> | off (변경 파일 집합을 diff 에서만 유도) | 이 변경이 건드리기로 선언된 파일 집합(쉼표 구분). diff 는 무엇이 바뀌었는지만 말하므로, 선언 밖 파일을 지목할 수 있게 한다. |
Parsing (
--base-sha <sha>):--base-sha다음 토큰을 값으로 취한다. 값이 없으면 플래그를 무시하고 기존 유도를 쓴다.
Parsing (
--declared-files <csv>):--declared-files다음 토큰을 값으로 취한다. 쉼표·공백을 포함할 수 있으므로 인용 부호로 감싸 넘긴다.
Usage: /cc-cmds:review-unattended <target> [--report-path <abs-path>] [--base-sha <sha>] [--declared-files <csv>] [--basis-cycle <n>] [--basis-review-head <sha>] [--basis-report-path <abs-path>] [--recover --scratch-dir <abs-path>] [<directive>]
| Option | Default | Summary |
|---|---|---|
<target> | (required) | 리뷰 대상. 드라이버가 방금 만든 PR 번호나 브랜치를 넘긴다. |
<directive> | (optional) | 리뷰 관점 지시문. severity 기준은 바꾸지 않고 팀 구성과 컨텍스트 가중치에만 영향. |
--report-path <abs-path> | off (리포트를 cwd 상대 docs/reviews/{slug}.md에 기록) | 뒤에 오는 메인 워크트리 절대 경로에 리포트를 기록한다. 세그먼트 워크트리에서 실행될 때 리포트가 그 트리에 떨어져 철거와 함께 파괴되는 것을 막는 유일한 수단. |
--base-sha <sha> | off (gh pr view … baseRefName 또는 기본 브랜치에서 base 를 스스로 유도) | diff 의 base 를 드라이버가 지정. 드라이버는 세그먼트가 갈라져 나온 base 를 이미 알고 있으므로, 이 값이 있으면 리뷰가 그것을 다시 유도하지 않는다. 넘겨받은 값은 신뢰하지 않고 git merge-base --is-ancestor 로 검증하며, 실패하면 기존 유도로 폴백하고 그 사실을 리포트 개요에 남긴다. |
--declared-files <csv> | off (변경 파일 집합을 diff 에서만 유도) | 이 세그먼트가 건드리기로 선언된 파일 집합(쉼표 구분). diff 는 무엇이 바뀌었는지만 말하고 무엇이 바뀌기로 되어 있었는지는 말하지 않으므로, 선언 밖 파일이 리뷰 범위 안에 있을 때 그것을 지목할 수 있게 한다. |
--basis-cycle <n> | off (delta mode is not attempted; review runs full) | The 사이클 number of this segment's most recent FULL review cycle. Required together with --basis-review-head and --basis-report-path to attempt delta mode — all three or none. Any one missing or malformed drops the whole attempt to a full review, never a halt. |
--basis-review-head <sha> | off (delta mode is not attempted; review runs full) | The 리뷰 HEAD of the cycle named by --basis-cycle. Verified with git merge-base --is-ancestor against the target head named explicitly, never the caller's ambient HEAD. On failure, or when the three-flag set is incomplete or malformed, the arm falls back to a full review and records why in the report overview. |
--basis-report-path <abs-path> | off (delta mode is not attempted; review runs full) | Main-worktree absolute path to the --basis-cycle report — the source of the prior findings this cycle re-adjudicates. Read-only; never written by this arm. Must be absolute or it is treated as malformed. |
--recover | off (팀을 띄워 Steps 2~4 를 정상 수행) | Steps 2~4 를 통째로 대체해 팀을 하나도 띄우지 않고, 드라이버가 지명한 위트니스 scratch 디렉터리의 디스크 내용만으로 리포트를 합성한다. 크래시로 죽은 리뷰 스테이지의 부분 산출물을 되살리는 경로. |
--scratch-dir <abs-path> | off (지명 없음 — 후보를 열거하고 하나가 지명될 때까지 아무것도 복구하지 않는다) | 드라이버가 지명한 위트니스 scratch 디렉터리. 한 논리 세그먼트가 여러 번 재시도되면 디렉터리도 여럿이고 각 시도가 자기 원장에서 epoch 1 을 얻으므로, 어느 시도를 관측했는지 아는 드라이버만 지명할 수 있다. |
Parsing (
<target>): 숫자만 포함된 토큰은 PR 번호, 하이픈·영문 포함 토큰은 브랜치로 해석. 어느 형태에도 해당되지 않으면 중단 기록을 남기고 정지.
Parsing (
<directive>): 타겟과 인식된 플래그(--report-path·--base-sha·--declared-files·--basis-cycle·--basis-review-head·--basis-report-path·--recover·--scratch-dir)의 값을 뺀 나머지. 인식되지 않는--토큰은 지시문으로 흡수하지 않고 폐기하며, 폐기 사실을 리포트에 한 줄 남긴다.
Parsing (
--report-path <abs-path>):--report-path다음 토큰을 값으로 취한다. 값이 없거나 절대 경로가 아니면 중단 기록을 남기고 정지.
Parsing (
--base-sha <sha>):--base-sha다음 토큰을 값으로 취한다. 값이 없으면 플래그를 무시하고 기존 유도를 쓴다 — 정지하지 않는다.
Parsing (
--declared-files <csv>):--declared-files다음 토큰을 값으로 취한다. 쉼표·공백을 포함할 수 있어 드라이버가 인용 부호로 감싸 넘긴다. 값이 없으면 플래그를 무시한다 — 정지하지 않는다.
Parsing (
--basis-cycle <n>):--basis-cycletakes the next token as its value. Missing, or not a positive integer, or either companion flag itself missing or malformed → all three are treated as absent for this call; full review, no halt. One overview line records the attempt only when at least one of the three was actually supplied on argv.
_Parsing (
--basis-review-head <sha>):--basis-review-headtakes the next token as its value. Value missing → treated as absent; see --basis-cycle's parse_note for the joint-absence rule._
_Parsing (
--basis-report-path <abs-path>):--basis-report-pathtakes the next token as its value. Value missing or not an absolute path → treated as absent; see --basis-cycle's parse_note for the joint-absence rule._
Parsing (
--recover): 값을 취하지 않는다. 이 플래그가 없으면 복구 절 전체가 발동하지 않는다.
Parsing (
--scratch-dir <abs-path>):--scratch-dir다음 토큰을 값으로 취한다. 값이 없거나 절대 경로가 아니면 지명이 없는 것으로 다뤄 열거 후 거부 경로로 간다.
Usage: /cc-cmds:review-upgrade
이 커맨드는 별도 인자를 받지 않으며, 직전 /review Step 3 리뷰어 구성 제안이 현재 대화 컨텍스트에 있어야 동작한다. opus 승격, 누락 리뷰 관점 추가, 과부하 리뷰어 분할은 강화가 유의미할 때만 제안하며, 그 외에는 유지 사유를 제시한다. 독립 실행 시 결과가 불정확할 수 있다.
<!-- SKILLS_OPTIONS_END -->
MIT
hooks/autopilot-status.tsx 349 lines1// 런 상태 패널 mod. 대화형 세션에서만 깨어나, 상태 표시줄이 고른 런 하나의 상태를
2// orchestrator/run-pane.sh 에서 받아 패널에 그린다. 문구·순서·색조·부류·주기와 줄 상한은
3// 모두 그 헬퍼가 정하고, 이 모듈은 언제 헬퍼를 부르고 언제 패널을 여닫는지만 정한다.
4// 이 모듈이 플러그인의 유일한 `modules` 진입이며, 질문지 서브 mod 의 등록도 넘겨준다.
5import { atom, read, update } from 'claude-code'
6import type { Hook, Register } from 'claude-code'
7
8import type { PaneControl, PaneLine, PaneLines, PanePart } from '../types'
9import { register as registerQuestionForm } from './question-form/index'
10
11type Dollar = Parameters<Hook<'session.start'>>[0]
12
13const PANE = 'autopilot-status'
14const TITLE = 'autopilot'
15const TICK_MS = 10_000
16const IDLE_MS = 60_000
17const HELPER_TIMEOUT_MS = 8000
18// 도크가 이 폭부터 놓이므로, 그보다 좁으면 명령이 열지 않고 안내만 띄운다.
19const DOCK_MIN_COLUMNS = 110
20// 도크에 요청하는 본문 폭. 헬퍼의 --cols 기본값과 같다.
21const DOCK_COLUMNS = 44
22// 시계가 이 시간 넘게 틱을 내지 않았으면 끝난 것으로 보고 다시 건다.
23const STALL_MS = 3 * TICK_MS
24const NOT_DOCKED = 'autopilot 패널은 전체 화면(110칸 이상)에서만 보입니다'
25// 런 id 기록이 세션 내내 쌓이지 않게 끝에서부터 이만큼만 남긴다.
26const KEEP_IDS = 100
27// 헬퍼가 내는 묶음의 닫힌 집합. 여기 없는 묶음의 줄은 버린다.
28const BUNDLES = new Set([
29 'none',
30 'title',
31 'head',
32 'head-detail',
33 'gap',
34 'seg-heading',
35 'seg',
36 'seg-detail',
37 'seg-folded',
38 'gate-none',
39 'approval',
40 'block-heading',
41 'block-reason',
42 'cone-unresolved',
43 'orphan',
44 'event-heading',
45 'event',
46])
47
48const snapshot = atom({ plugin: 'cc-cmds', key: 'paneLines' } as const, {
49 lines: [],
50 warning: null,
51} as PaneLines)
52
53const control = atom({ plugin: 'cc-cmds', key: 'paneControl' } as const, {
54 autoOpenedFor: [],
55 dismissedFor: [],
56 lastRunAt: null,
57 lastMtime: null,
58 lastSid: null,
59 rid: null,
60 indexPath: null,
61 refreshMs: IDLE_MS,
62} as PaneControl)
63
64type Head = { kind: string; rid: string | null; indexPath: string | null; refreshMs: number }
65
66// 조각 하나의 색조 `<normal|dim|ok|warn|error|accent>[.b]` 를 색조와 굵기로 나눈다.
67const part = (tone: string, text: string): PanePart =>
68 tone.endsWith('.b') ? { tone: tone.slice(0, -2), bold: true, text } : { tone, bold: false, text }
69
70// 헬퍼 출력 규약 cc-pane 2: 머리 행 `cc-pane<TAB>2<TAB>부류<TAB>rid<TAB>목록 경로<TAB>refresh_ms`,
71// 이어서 `묶음<TAB><cut|wrap><TAB>색조<TAB>글[<TAB>색조<TAB>글]…` 본문. 머리 행이 규약과
72// 다르면 그 까닭을, 맞으면 머리와 본문을 돌려준다. 스키마는 정확히 2 만 받는다.
73const parse = (stdout: string): { head: Head; lines: PaneLine[] } | string => {
74 const rows = stdout.split('\n').filter(row => row !== '')
75 const cells = rows.length > 0 ? rows[0].split('\t') : []
76 if (cells.length !== 6 || cells[0] !== 'cc-pane') return '출력 형식이 맞지 않음'
77 if (cells[1] !== '2') return `헬퍼 규약 cc-pane ${cells[1]} 은 읽지 않음`
78 const refreshMs = Number(cells[5])
79 const head: Head = {
80 kind: cells[2],
81 rid: cells[3] === '-' || cells[3] === '' ? null : cells[3],
82 indexPath: cells[4].startsWith('/') ? cells[4] : null,
83 refreshMs: Number.isFinite(refreshMs) && refreshMs > 0 ? refreshMs : IDLE_MS,
84 }
85 const lines: PaneLine[] = []
86 for (const row of rows.slice(1)) {
87 const [bundle, mode, ...rest] = row.split('\t')
88 if (!BUNDLES.has(bundle)) continue
89 const parts: PanePart[] = []
90 for (let i = 0; i < rest.length; i += 2) parts.push(part(rest[i], rest[i + 1] ?? ''))
91 lines.push({ bundle, wrap: mode === 'wrap', parts })
92 }
93
94 return { head, lines }
95}
96
97const remember = (ids: readonly string[], id: string): string[] =>
98 ids.includes(id) ? [...ids] : [...ids, id].slice(-KEEP_IDS)
99
100// ui.close 뒤의 닫힘 기록. 사람이 닫은 것만 현재 rid 로 기록한다. 명령의 닫기(plugin)는
101// 명령 처리기가 직접 기록하고, 적재 해제(unload)는 기록하지 않는다.
102export const noteClose = (c: PaneControl, origin: string): PaneControl =>
103 origin === 'person' && c.rid !== null ? { ...c, dismissedFor: remember(c.dismissedFor, c.rid) } : c
104
105// 색조를 테마 키나 Text 속성으로 바꾼다. 모르는 색조는 normal 처럼 속성 없이 그린다.
106const toneProps = (tone: string, bold: boolean) => {
107 const weight = bold ? ({ bold: true } as const) : {}
108 switch (tone) {
109 case 'ok':
110 return { color: 'success', ...weight } as const
111 case 'warn':
112 return { color: 'warning', ...weight } as const
113 case 'error':
114 return { color: 'error', ...weight } as const
115 case 'accent':
116 return { color: 'suggestion', ...weight } as const
117 case 'dim':
118 return { dimColor: true, ...weight } as const
119 default:
120 return weight
121 }
122}
123
124// 아래는 모두 모듈 변수다. 모듈이 다시 적재되면 초기값으로 돌아가고, 이전 시계도 함께
125// 사라진다. 그래서 재적재 뒤에는 첫 띠 렌더 전까지 저절로 열지 않고, 첫 도크 렌더
126// 전까지 헬퍼 기본 폭을 쓴다.
127// 헬퍼는 한 번에 하나만 돈다.
128let isRunning = false
129let timer: { cancel: () => void } | undefined
130// session.start 가 대화형·비파이프라인 가드를 통과해 시계를 걸었는지. 이 세션에서만
131// 시계를 다시 건다.
132let isArmed = false
133// 마지막 틱이 시작한 시각. 시계를 건 시각이 초기값이다.
134let lastTickAt = 0
135// 직전 틱이 본 패널의 놓임.
136let wasPlaced = false
137// 띠 렌더가 알려 준 전체 화면 여부. 알기 전에는 undefined.
138let layout: boolean | undefined
139// 마지막 도크 렌더의 본문 폭과 행 수. 도크로 그려지기 전에는 undefined.
140let dock: { columns: number; rows: number } | undefined
141
142const isCount = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n > 0
143
144async function statMtime($: Dollar, path: string | null): Promise<number | null> {
145 if (path === null || !path.startsWith('/')) return null
146 try {
147 return (await $.fs.stat(path)).mtimeMs
148 } catch {
149 // 목록이 아직 없거나 지워졌다(ENOENT). 「없음」으로 본다.
150 return null
151 }
152}
153
154// 헬퍼를 한 번 돌리고 결과를 상태에 반영한다. 실패하면 마지막 좋은 줄을 두고 경고 줄만
155// 더하며, 자동으로 열지 않는다. tickPath 는 tickMtime 을 잰 목록 경로이고, startedAt 은
156// 헬퍼를 부르기 전에 읽은 시각이다. 헬퍼가 걸린 시간을 다음 기한에 더하지 않도록
157// lastRunAt 에는 이 값을 적는다.
158async function runHelper($: Dollar, sid: string, tickMtime: number | null, tickPath: string | null, startedAt: number) {
159 if (isRunning) return
160 isRunning = true
161 try {
162 let parsed: ReturnType<typeof parse> = '실행하지 못함'
163 let failure: string | null = null
164 const size = dock === undefined ? [] : ['--cols', String(dock.columns), '--rows', String(dock.rows)]
165 try {
166 const ran = await $.process.run(['bash', `${$.plugin.root}/orchestrator/run-pane.sh`, sid, ...size], {
167 timeoutMs: HELPER_TIMEOUT_MS,
168 })
169 if (ran.exitCode !== 0) failure = `종료 코드 ${ran.exitCode}`
170 else parsed = parse(ran.stdout)
171 } catch {
172 failure = '실행하지 못함'
173 }
174
175 if (failure !== null || typeof parsed === 'string') {
176 const why = failure ?? parsed
177 await update($, snapshot, s => ({ ...s, warning: `⚠ 런 상태를 읽지 못했다 (${why})` }))
178 await update($, control, c => ({ ...c, lastRunAt: startedAt, lastSid: sid, lastMtime: tickMtime }))
179
180 return
181 }
182
183 const { head, lines } = parsed
184 const mtime = head.indexPath === tickPath ? tickMtime : await statMtime($, head.indexPath)
185 await update($, snapshot, () => ({ lines, warning: null }))
186 const before = await update($, control, c => ({
187 ...c,
188 lastRunAt: startedAt,
189 lastSid: sid,
190 lastMtime: mtime,
191 rid: head.rid,
192 indexPath: head.indexPath,
193 refreshMs: head.refreshMs,
194 }))
195
196 // 자동 열기: 전체 화면인 줄 안 세션에서, 살아 있는 런마다 한 번, 사람이 닫은 적 없는
197 // 런만, 패널이 이미 열려 있지 않을 때만. 놓였는지가 아니라 열려 있는지로 본다 —
198 // 좁은 터미널에서 기다리는 패널도 열려 있는 것이다.
199 const rid = head.rid
200 if (layout !== true || head.kind !== 'live' || rid === null) return
201 if (before.autoOpenedFor.includes(rid) || before.dismissedFor.includes(rid)) return
202 const panes = await $.ui.panes()
203 if (panes.some(pane => pane.id === PANE)) return
204 await update($, control, c => ({ ...c, autoOpenedFor: remember(c.autoOpenedFor, rid) }))
205 await $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS })
206 } finally {
207 isRunning = false
208 }
209}
210
211// 매 틱은 프로세스 없이 세션 id·패널·목록 mtime 만 보고, 조건이 맞을 때만 헬퍼를 부른다.
212// 기한에는 반 틱의 여유를 둔다. 틱마다 시계를 읽기까지의 지연이 엇갈려도 놓인 live
213// 패널이 한 틱을 건너뛰지 않게 하기 위해서다.
214async function tick($: Dollar) {
215 const now = await $.clock.now()
216 lastTickAt = now
217 if (isRunning) return
218 const sid = await $.session.id()
219 const panes = await $.ui.panes()
220 const isPlaced = panes.find(one => one.id === PANE)?.isPlaced === true
221 // 놓임을 알리는 사건이 없으므로, 거짓에서 참으로 바뀐 것을 본 틱에서 곧바로 돌린다.
222 const becamePlaced = isPlaced && !wasPlaced
223 wasPlaced = isPlaced
224 const c = await read($, control)
225 const mtime = await statMtime($, c.indexPath)
226 const period = isPlaced ? c.refreshMs : IDLE_MS
227 const isDue =
228 c.lastRunAt === null ||
229 sid !== c.lastSid ||
230 mtime !== c.lastMtime ||
231 becamePlaced ||
232 now - c.lastRunAt >= period - TICK_MS / 2
233 if (isDue) await runHelper($, sid, mtime, c.indexPath, now)
234}
235
236// 시계 콜백은 tick 을 기다리지 않고 곧바로 돌아온다. 그래야 주기가 고정된 채로 남는다.
237const arm = ($: Dollar) => {
238 timer?.cancel()
239 timer = $.clock.every(TICK_MS, () => {
240 tick($).catch(() => undefined)
241 })
242}
243
244// 시계는 거부된 주기에서 끝날 수 있다. 시계를 건 세션에서 틱이 오래 없었으면 다시 건다.
245async function rearmIfStalled($: Dollar) {
246 if (!isArmed) return
247 const now = await $.clock.now()
248 if (now - lastTickAt <= STALL_MS) return
249 lastTickAt = now
250 arm($)
251}
252
253export const register: Register = (on, options) => {
254 on('session.start', async ($, e, next) => {
255 if (!e.isInteractive) return next(e)
256 const segment = await $.env.get('CC_PIPELINE_SEGMENT')
257 const runId = await $.env.get('CC_PIPELINE_RUN_ID')
258 const stageId = await $.env.get('CC_PIPELINE_STAGE_ID')
259 const shiftId = await $.env.get('CC_PIPELINE_SHIFT_ID')
260 if (segment || runId || stageId || shiftId) return next(e)
261
262 await $.command.register({
263 name: 'autopilot-status',
264 description: '런 상태 패널을 열거나 닫는다',
265 immediate: true,
266 })
267 lastTickAt = await $.clock.now()
268 isArmed = true
269 arm($)
270
271 return next(e)
272 })
273
274 // 명령은 「놓여 있음」으로 토글한다. 사람이 보는 것은 놓인 패널뿐이기 때문이다. 놓이지
275 // 않은 패널을 도킹할 수 없는 화면이면 열지 않고 안내 한 줄만 띄운다.
276 on('command.run', { command: 'autopilot-status' }, async ($, e) => {
277 layout = e.presentation.isFullscreen
278 await rearmIfStalled($)
279 const pane = (await $.ui.panes()).find(one => one.id === PANE)
280 if (pane?.isPlaced) {
281 // 이 닫기는 출처가 plugin 으로 오므로 ui.close 훅이 기록하지 않는다. 여기서 기록한다.
282 const { rid } = await read($, control)
283 if (rid !== null) await update($, control, c => ({ ...c, dismissedFor: remember(c.dismissedFor, rid) }))
284 await $.ui.close({ id: PANE })
285
286 return {}
287 }
288 if (!e.presentation.isFullscreen || e.presentation.columns < DOCK_MIN_COLUMNS) {
289 $.ui.toast(`${NOT_DOCKED} — plugins/cc-cmds/hooks/README.md`)
290
291 return {}
292 }
293 await $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS })
294 const sid = await $.session.id()
295 const c = await read($, control)
296 const mtime = await statMtime($, c.indexPath)
297 await runHelper($, sid, mtime, c.indexPath, await $.clock.now())
298
299 return {}
300 })
301
302 // 사람이 닫은 런으로는 다시 저절로 열지 않는다. 기록이 실패해도 닫기는 막지 않는다.
303 on('ui.close', async ($, e, next) => {
304 await rearmIfStalled($)
305 if (e.id === PANE && e.origin.kind === 'person') await update($, control, c => noteClose(c, 'person'))
306
307 return next(e)
308 }).catch(($, e, next) => next(e))
309
310 // 띠는 그리지 않고 지나가며, 이 세션이 전체 화면인지만 적는다. 자동 열기는 이것을 본다.
311 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
312 layout = e.viewport?.isFullscreen
313
314 return next(e)
315 })
316
317 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318 const { Box, Text } = $.ui.resolve(e)
319 if (e.props.placement !== 'dock') return <Text dimColor wrap="truncate-end">{NOT_DOCKED}</Text>
320 // 도크의 폭과 행 수는 다음 헬퍼 실행이 줄 상한과 감기 추정에 쓴다.
321 const columns = e.props.bodyColumns
322 const rows = e.props.scroll.bodyRows
323 if (isCount(columns) && isCount(rows)) dock = { columns, rows }
324 const { lines, warning } = await read($, snapshot)
325
326 // 한 줄은 바깥 Text 하나이고 조각은 그 안의 Text 다. 그래야 조각마다 색이 남은 채
327 // 줄 전체가 한 번 감기거나 한 번 잘린다. 빈 줄은 높이를 잃지 않게 공백 하나로 그린다.
328 return (
329 <Box flexDirection="column">
330 {lines.length === 0 && warning === null && <Text dimColor>런 상태를 읽는 중</Text>}
331 {lines.map(line => (
332 <Text wrap={line.wrap ? 'wrap' : 'truncate-end'}>
333 {line.parts.every(one => one.text === '')
334 ? ' '
335 : line.parts.map(one => <Text {...toneProps(one.tone, one.bold)}>{one.text}</Text>)}
336 </Text>
337 ))}
338 {warning !== null && (
339 <Text color="warning" wrap="truncate-end">
340 {warning}
341 </Text>
342 )}
343 </Box>
344 )
345 })
346
347 registerQuestionForm(on, options)
348}
349hooks/question-form/index.tsx 424 lines1// 질문지 서브 mod. 모델이 mcp__cc-cmds__question_form 으로 여러 질문을 한 장의 패널로
2// 묻고, 사람이 제출하면 답 묶음을 플러그인 출처의 새 프롬프트로 보내고 패널을 닫는다.
3// 훅 안에서 사람을 기다리지 않는다 — 도구는 열기만 하고 턴을 끝내게 하며, 답은 누름
4// 처리기가 낸다. `$` 는 import 너머로 넘어가지 않으므로 `$` 를 쓰는 함수는 모두 이 파일에 둔다.
5import { atom, read, update } from 'claude-code'
6import type { Hook, Register } from 'claude-code'
7
8import { anyMarked } from '../pipeline-marks'
9import { bannerCall } from './banner'
10import { bundleText, counts, mimicsHeader, mintFormId } from './bundle'
11import type { FormStatus } from './bundle'
12import { SUBMIT_KEY, landingKey, layoutRows, paneSize } from './layout'
13import { drawForm } from './render'
14import type { FormHandlers } from './render'
15import {
16 INPUT_SCHEMA,
17 NO_FORM_TOAST,
18 PANE_ID,
19 STAMP,
20 TOOL_DESCRIPTION,
21 TOOL_NAME,
22 busyResult,
23 contextLine,
24 formTitle,
25 openResult,
26 sentToast,
27 statusLine,
28 unavailableResult,
29} from './spec'
30import type { FormInput } from './spec'
31import {
32 advance,
33 cancel,
34 carryDrafts,
35 choose,
36 commitText,
37 decideCall,
38 expired,
39 isOpen,
40 jump,
41 openEditor,
42 openRecord,
43 personClose,
44 placed,
45 reopen,
46 restorable,
47 retreat,
48 submit,
49 typeText,
50} from './transitions'
51import type { FormEditor, FormRecord, Move } from './transitions'
52import { normalizeForm, validateForm } from './validate'
53
54type Dollar = Parameters<Hook<'session.start'>>[0]
55
56const recordAtom = atom({ plugin: 'cc-cmds', key: 'questionForm.record' } as const, null as FormRecord | null)
57const lastOriginAtom = atom({ plugin: 'cc-cmds', key: 'questionForm.lastPersonOrigin' } as const, null as string | null)
58const focusedAtom = atom({ plugin: 'cc-cmds', key: 'questionForm.focusedElement' } as const, null as string | null)
59
60const STORE_PREFIX = 'questionForm.open.'
61
62// 마지막으로 그린 패널 본문의 칸 수. 크기를 잴 때 줄이 감기는 폭으로 쓴다. 그리는 중에는
63// 상태를 쓸 수 없으므로 모듈 변수에 둔다 — 틀려도 크기 요청이 조금 어긋날 뿐이다.
64let lastBodyColumns = 80
65
66// 머리줄을 흉내 낸 프롬프트 가운데 이 플러그인의 제출이 아닌 것에 도장을 단다.
67const needsStamp = (e: { text: string; origin: { kind: string; name?: unknown } }) =>
68 mimicsHeader(e.text) && !(e.origin.kind === 'plugin' && e.origin.name === 'cc-cmds')
69
70// 도구 호출이 상태를 쓰기 전의 기록. .catch 가 이 호출이 만든 것을 되돌릴 때 읽는다.
71const before = new Map<string, FormRecord | null>()
72
73// 표지 넷을 리터럴로 읽는다. 셸 쪽 라우터 술어(셋)의 엄격한 상위 집합이다.
74async function pipelineMarked($: Dollar): Promise<boolean> {
75 return anyMarked([
76 await $.env.get('CC_PIPELINE_SEGMENT'),
77 await $.env.get('CC_PIPELINE_STAGE_ID'),
78 await $.env.get('CC_PIPELINE_SHIFT_ID'),
79 await $.env.get('CC_PIPELINE_RUN_ID'),
80 ])
81}
82
83// 프로세스를 넘는 보관: 열린 기록만 세션 id 아래에 두고, 아니면 지운다. 키는 기록을 연
84// 세션의 것이고, 기록이 없으면 sessionId 를, 그것도 없으면 지금 세션을 쓴다. 보관 실패는
85// 질문지 자체를 막지 않는다.
86async function mirror($: Dollar, rec: FormRecord | null, sessionId?: string) {
87 try {
88 const key = STORE_PREFIX + (rec?.sessionId ?? sessionId ?? (await $.session.id()))
89 if (isOpen(rec ?? undefined)) await $.store.set(key, rec)
90 else await $.store.delete(key)
91 } catch {
92 // 보관은 덤이다.
93 }
94}
95
96async function writeRecord($: Dollar, rec: FormRecord | null) {
97 await update($, recordAtom, () => rec)
98 await mirror($, rec)
99}
100
101// 패널을 열 때 함께 주는 제목과 크기. 크기는 지금 보이는 줄에서 잰다.
102type Frame = { title: string; rows: number; columns: number }
103
104function frameOf(rec: FormRecord): Frame {
105 const { answered, total } = counts(rec.form, rec.drafts)
106 return { title: formTitle(rec.form.title, answered, total), ...paneSize(rec, lastBodyColumns) }
107}
108
109const sameFrame = (a: Frame, b: Frame) => a.title === b.title && a.rows === b.rows && a.columns === b.columns
110
111const openPane = ($: Dollar, rec: FormRecord, focus?: true) =>
112 $.ui.open({ id: PANE_ID, ...frameOf(rec), ...(focus ? { focus } : {}) })
113
114const statusOf = (rec: FormRecord) => {
115 const { answered, total } = counts(rec.form, rec.drafts)
116 return statusLine(answered, total)
117}
118
119// 새로 연 질문지의 배너. 실패는 삼킨다 — 배너가 실패해도 질문지는 열린다.
120async function banner($: Dollar, form: FormInput) {
121 try {
122 const call = bannerCall($.plugin.root, await $.session.id(), form)
123 await $.process.run(call.argv, { stdin: call.stdin, env: call.env, timeoutMs: call.timeoutMs })
124 } catch {
125 // 배너 없이 연다.
126 }
127}
128
129// 다시 불러온 뒤나 프로세스를 넘어 되살린 질문지를 요청 없이 다시 연다. 배치 문턱에
130// 못 미치면 그려지지 않으므로 상태 줄을 띄운다.
131async function reshow($: Dollar, rec: FormRecord) {
132 const opened = await openPane($, rec)
133 const next = placed(rec, opened.isPlaced)
134 await writeRecord($, next)
135 await $.ui.status(next.hidden ? statusOf(next) : undefined)
136}
137
138// 이 세션이 연 기록만 되살린다. 다른 세션의 기록은 상태에서 내리고, 그 보관 키는 그
139// 세션으로 돌아올 때를 위해 남긴다. 7일이 지난 보관은 이 세션의 것이어도 지운다.
140async function restore($: Dollar) {
141 const sid = await $.session.id()
142 const now = await $.clock.now()
143 const held = restorable((await read($, recordAtom)) ?? undefined, sid)
144 if (!held) await update($, recordAtom, r => (isOpen(r ?? undefined) ? null : r))
145 let show = held
146 for (const key of await $.store.keys()) {
147 if (!key.startsWith(STORE_PREFIX)) continue
148 const stored = (await $.store.get(key)) as FormRecord | undefined
149 if (key === STORE_PREFIX + sid) {
150 if (held) continue
151 show = restorable(stored, sid, now)
152 if (!show) await $.store.delete(key)
153 } else if (!stored || expired(stored, now)) {
154 await $.store.delete(key)
155 }
156 }
157 if (show) await reshow($, show)
158}
159
160// 프로세스 안 /resume 으로 앞 대화에 돌아오면 session.start 가 오지 않아 restore 가 돌지
161// 않는다. 그래서 사람이 그 대화에서 프롬프트나 /question-form 을 칠 때, 메모리에 기록이
162// 없으면 지금 세션의 보관을 되살린다. 7일이 지난 보관은 되살리지 않고 지운다.
163async function rejoin($: Dollar) {
164 if ((await read($, recordAtom)) !== null) return
165 const sid = await $.session.id()
166 const key = STORE_PREFIX + sid
167 const stored = (await $.store.get(key)) as FormRecord | undefined
168 if (!stored) return
169 const alive = restorable(stored, sid, await $.clock.now())
170 if (alive) await reshow($, alive)
171 else await $.store.delete(key)
172}
173
174// 묶음을 내는 공통 경로: CAS 로 기록을 소비한 쪽만 제출한다. 묶음을 낸 자리에서 패널을
175// 닫고 기록을 버린다 — 답은 묶음이 들고 가고, 다음 질문지는 새로 열린다.
176async function finish($: Dollar, status: FormStatus) {
177 let taken: FormRecord | undefined
178 await update($, recordAtom, r => {
179 taken = status === '제출' ? submit(r ?? undefined) : cancel(r ?? undefined)
180 return taken ? null : r
181 })
182 if (!taken) return
183 const done = taken
184 await mirror($, null, done.sessionId)
185 await $.prompt.submit({ text: bundleText(done.id, status, done.form, done.drafts) })
186 await $.ui.status(undefined)
187 await $.ui.close({ id: PANE_ID })
188 if (status === '제출') {
189 const { answered, total } = counts(done.form, done.drafts)
190 await $.ui.toast(sentToast(answered, total))
191 }
192}
193
194// 열린 기록을 순수 전이로 바꾼다. 제목이나 바라는 크기가 바뀌었으면 패널을 다시 열고,
195// 전이가 포커스 자리를 정했으면 그리로 옮긴다(옮기지 못해도 답은 그대로다).
196async function change($: Dollar, fn: (rec: FormRecord) => { record: FormRecord; focus?: string }) {
197 let after: { record: FormRecord; focus?: string } | undefined
198 let was: Frame | undefined
199 await update($, recordAtom, r => {
200 if (!isOpen(r ?? undefined)) return r
201 const rec = r as FormRecord
202 was = frameOf(rec)
203 after = fn(rec)
204 return after.record
205 })
206 if (!after || !was) return
207 await mirror($, after.record)
208 if (!sameFrame(frameOf(after.record), was)) await openPane($, after.record)
209 if (after.focus) {
210 try {
211 await $.ui.focus({ requestId: PANE_ID, key: after.focus })
212 } catch {
213 // 포커스는 덤이다.
214 }
215 }
216}
217
218// 전이가 말한 이동을 포커스 자리로: 다음 질문의 첫 조작부, 질문이 끝나면 [제출].
219const focusOf = (rec: FormRecord, move: Move) => (move === 'next' ? landingKey(rec) : move === 'end' ? SUBMIT_KEY : undefined)
220
221function handlers($: Dollar): FormHandlers {
222 const run = (fn: (rec: FormRecord) => { record: FormRecord; focus?: string }) => void change($, fn).catch(() => undefined)
223 const moved = (r: { record: FormRecord; move: Move }) => ({ record: r.record, focus: focusOf(r.record, r.move) })
224 const step = (go: (rec: FormRecord) => FormRecord | undefined) => (rec: FormRecord) => {
225 const next = go(rec)
226 return next ? { record: next, focus: landingKey(next) } : { record: rec, focus: SUBMIT_KEY }
227 }
228 return {
229 choose: (qid, label) => run(rec => moved(choose(rec, qid, label))),
230 jump: qid => run(rec => {
231 const next = jump(rec, qid)
232 return { record: next, focus: landingKey(next) }
233 }),
234 openEditor: (qid: string, field: FormEditor['field']) => run(rec => {
235 const next = openEditor(rec, qid, field)
236 return { record: next, focus: landingKey(next) }
237 }),
238 typeText: (qid, field, value) => run(rec => ({ record: typeText(rec, qid, field, value) })),
239 commitText: (qid, field, value) => run(rec => moved(commitText(rec, qid, field, value))),
240 next: () => run(step(advance)),
241 prev: () => run(rec => {
242 const back = retreat(rec)
243 return back ? { record: back, focus: landingKey(back) } : { record: rec }
244 }),
245 submit: () => void finish($, '제출').catch(() => undefined),
246 cancel: () => void finish($, '취소').catch(() => undefined),
247 }
248}
249
250export const register: Register = on => {
251 // 대화형 세션에서만, 파이프라인 표지가 없을 때만 도구와 명령을 등록한다.
252 on('session.start', { isInteractive: true }, async ($, e, next) => {
253 // 등록이나 되살리기가 실패해도 세션과 같은 플러그인의 다른 시작 훅은 그대로 간다.
254 // 도구가 등록되지 않으면 스킬은 ToolSearch 결과로 그것을 알고 AskUserQuestion 으로 묻는다.
255 try {
256 if (!(await pipelineMarked($))) {
257 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
258 await $.command.register({ name: 'question-form', description: '열린 질문지 패널을 다시 연다', immediate: true })
259 await restore($)
260 }
261 } catch {
262 // 위 주석과 같다.
263 }
264
265 return next(e)
266 })
267
268 // 매처는 validate 가 소스에서 읽으므로 상수가 아니라 리터럴로 적는다.
269 on('tool.call', { tool: 'mcp__cc-cmds__question_form' }, async ($, e) => {
270 if (e.agentId) return { deny: unavailableResult('subagent') }
271 if (await pipelineMarked($)) return { deny: unavailableResult('pipeline') }
272 if (!(await $.session.surfaces()).includes('terminal')) return { deny: unavailableResult('surface') }
273 if ((await read($, lastOriginAtom)) === 'bridge') return { deny: unavailableResult('remote') }
274
275 const args = e as unknown as Record<string, unknown>
276 const input = { title: args.title, intro: args.intro, replaces: args.replaces, questions: args.questions }
277 const current = (await read($, recordAtom)) ?? undefined
278 const invalid = validateForm(input, isOpen(current) ? current.id : undefined)
279 if (invalid) return { deny: invalid }
280 const decision = decideCall(current, input.replaces as string | undefined)
281 if (decision.kind === 'busy') return { deny: busyResult(decision.id) }
282
283 const form = normalizeForm(input as FormInput)
284 const id = mintFormId()
285 const drafts = decision.kind === 'replace' && current ? carryDrafts(current.form, current.drafts, form) : {}
286 let rec = openRecord({ id, toolUseId: e.tool_use_id, form, drafts, sessionId: await $.session.id(), now: await $.clock.now() })
287 before.set(e.tool_use_id, current ?? null)
288 await writeRecord($, rec)
289 const opened = await openPane($, rec, true)
290 if (!opened.isPlaced) {
291 rec = placed(rec, false)
292 await writeRecord($, rec)
293 }
294 await $.ui.status(rec.hidden ? statusOf(rec) : undefined)
295 before.delete(e.tool_use_id)
296 if (decision.kind === 'new') await banner($, form)
297
298 return { result: openResult(id, form.questions.length, opened.isPlaced) }
299 }).catch(async ($, e) => {
300 // 이 호출이 만든 기록과 패널을 지우고 거절한다.
301 if (before.has(e.tool_use_id)) {
302 const prev = before.get(e.tool_use_id) ?? null
303 before.delete(e.tool_use_id)
304 try {
305 await writeRecord($, prev)
306 if (!prev) await $.ui.close({ id: PANE_ID })
307 await $.ui.status(undefined)
308 } catch {
309 // 되돌리기도 실패하면 거절만 한다.
310 }
311 }
312
313 return { deny: unavailableResult('error') }
314 })
315
316 // 사람의 프롬프트: 머리줄을 흉내 낸 사본에 도장, 출처 기록, 돌아온 대화의 보관
317 // 되살리기, 열린 질문지가 있으면 맥락 줄. 이 mod 자신의 제출은 이 훅을 지나지 않는다.
318 on('prompt.submit', async ($, e, next) => {
319 const context = [...(e.context ?? [])]
320 const origin = e.origin
321 if (needsStamp(e)) context.push(STAMP)
322 if (origin.kind === 'composer' || origin.kind === 'bridge') {
323 await update($, lastOriginAtom, () => origin.kind)
324 try {
325 await rejoin($)
326 } catch {
327 // 되살리지 못해도 맥락 줄과 출처 기록은 그대로 간다.
328 }
329 const rec = await read($, recordAtom)
330 if (isOpen(rec ?? undefined)) context.push(contextLine((rec as FormRecord).id))
331 }
332
333 return next(context.length === (e.context ?? []).length ? e : { ...e, context })
334 }).catch(($, e, next) => {
335 // 출처 기록이나 맥락 줄이 실패해도 도장은 빠지지 않는다.
336 if (!needsStamp(e)) return next(e)
337
338 return next({ ...e, context: [...(e.context ?? []), STAMP] })
339 })
340
341 // 사람의 닫기 표시만 다룬다: 숨기고 상태 줄을 띄운다. 이 mod 자신의 닫기는 지나지 않는다.
342 on('ui.close', { id: 'cc-cmds-question-form' }, async ($, e, next) => {
343 if (e.origin.kind === 'person') {
344 const { record, showStatus } = personClose((await read($, recordAtom)) ?? undefined)
345 await writeRecord($, record ?? null)
346 if (showStatus && record) await $.ui.status(statusOf(record))
347 }
348
349 return next(e)
350 }).catch(($, e, next) => next(e))
351
352 on('ui.render', { component: 'Pane', requestId: 'cc-cmds-question-form' }, async ($, e) => {
353 const el = $.ui.resolve(e)
354 lastBodyColumns = e.props.bodyColumns
355 const rec = await read($, recordAtom)
356 const focused = await read($, focusedAtom)
357 if (rec && isOpen(rec)) {
358 return drawForm(
359 el,
360 layoutRows(rec, focused, e.props.bodyColumns),
361 rec.form.questions.map(q => q.id),
362 handlers($),
363 )
364 }
365 const { Text } = el
366
367 return <Text dimColor>{NO_FORM_TOAST}</Text>
368 })
369
370 // 포커스 이동은 막지 않는다. 옮겨진 뒤에만 포커스를 받은 요소를 적는다.
371 on('ui.focus', { component: 'Pane', requestId: 'cc-cmds-question-form' }, async ($, e, next) => {
372 const r = await next(e)
373 if (!r.deny) {
374 try {
375 await update($, focusedAtom, () => e.element ?? null)
376 } catch {
377 // 미리보기만 늦는다.
378 }
379 }
380
381 return r
382 }).catch(($, e, next) => next(e))
383
384 // 사람이 친 명령이므로 폭과 무관하게 패널이 놓인다.
385 on('command.run', { command: 'question-form' }, async $ => {
386 try {
387 await rejoin($)
388 } catch {
389 // 되살리지 못하면 메모리의 기록만 본다.
390 }
391 const rec = reopen((await read($, recordAtom)) ?? undefined)
392 if (!rec) {
393 await $.ui.toast(NO_FORM_TOAST)
394
395 return {}
396 }
397 await writeRecord($, rec)
398 await openPane($, rec, true)
399 await $.ui.status(undefined)
400
401 return {}
402 })
403
404 // 프로세스가 다른 세션으로 이어지는 두 끝(/clear, 프로세스 안 /resume)에서는 끝나는
405 // 대화의 질문지를 다음 대화에 남기지 않는다. 그 대화를 버리는 /clear 는 보관도 지우고,
406 // /resume 은 보관을 남긴다. 같은 프로세스에서 그 대화로 돌아오면 첫 프롬프트나
407 // /question-form 에서 rejoin 이, 새 프로세스로 다시 열면 session.start 의 restore 가
408 // 되살린다.
409 on('session.end', async ($, e, next) => {
410 if (e.reason === 'clear' || e.reason === 'resume') {
411 await update($, recordAtom, () => null)
412 try {
413 if (e.reason === 'clear') await $.store.delete(STORE_PREFIX + e.sessionId)
414 await $.ui.close({ id: PANE_ID })
415 } catch {
416 // 보관은 덤이다.
417 }
418 await $.ui.status(undefined)
419 }
420
421 return next(e)
422 })
423}
424hooks/pipeline-marks.ts 8 lines1// 파이프라인 표지 판정. `$` 를 받지 않는 순수 함수만 둔다 — 표지 변수는
2// 부르는 쪽이 같은 파일에서 리터럴 이름으로 읽어 값만 넘긴다.
3
4// 값이 참인 표지가 하나라도 있으면 참. 정의돼 있어도 빈 문자열이면 표지가 아니다.
5export function anyMarked(values: readonly (string | undefined | null)[]): boolean {
6 return values.some(v => typeof v === 'string' && v !== '')
7}
8hooks/question-form/banner.ts 24 lines1// 새로 연 질문지의 배너: 셸 훅 session-ask-notify.sh 를 AskUserQuestion 과
2// 같은 PreToolUse stdin 모양으로 부르기 위한 인자를 만든다. `$` 를 쓰지 않으며
3// 실제 실행은 index.tsx 가 한다. agent_id 는 넣지 않는다(주 세션의 질문지다).
4
5import { TOOL_FULL_NAME } from './spec'
6import type { FormInput } from './spec'
7
8export const BANNER_TIMEOUT_MS = 5000
9
10export function bannerCall(root: string, sessionId: string, form: FormInput) {
11 const stdin = JSON.stringify({
12 hook_event_name: 'PreToolUse',
13 session_id: sessionId,
14 tool_name: TOOL_FULL_NAME,
15 tool_input: { questions: form.questions.map(q => ({ header: q.header, question: q.question })) },
16 })
17 return {
18 argv: ['/bin/bash', `${root}/hooks/session-ask-notify.sh`],
19 stdin,
20 env: { CLAUDE_PLUGIN_ROOT: root },
21 timeoutMs: BANNER_TIMEOUT_MS,
22 }
23}
24hooks/question-form/bundle.ts 139 lines1// 답 묶음: 머리줄, 머리줄 정규식, `cc-form-answers/1` JSON, 문답 기록의
2// `### 답 n` 본문과 같은 `answer` 문면. `$` 를 쓰지 않는다.
3
4import { NOT_APPLICABLE, UNANSWERED } from './spec'
5import type { Draft, Drafts, FormInput, FormOption, FormQuestion } from '../../types'
6
7export type { Draft, Drafts }
8
9export type FormStatus = '제출' | '취소'
10
11export const EMPTY_DRAFT: Draft = { selected: [], other: '', note: '' }
12
13export const HEADER_RE = /^\[cc-cmds 질문지 답\] form=(f-[0-9a-f]{8}) status=(제출|취소) /
14
15const HEADER_MARK = '[cc-cmds 질문지 답]'
16
17// 도장을 달 문면인가: 머리줄 표지가 어디에든 있으면 흉내다. 앞에 붙은 줄바꿈·공백이나
18// 분해형(NFD) 한글로 머리줄 정규식을 비껴가도 도장이 빠지지 않게 정규화한 뒤 찾는다.
19export const mimicsHeader = (text: string): boolean => text.normalize('NFC').includes(HEADER_MARK)
20
21export function mintFormId(random: () => number = Math.random): string {
22 let hex = ''
23 for (let i = 0; i < 8; i++) hex += Math.floor(random() * 16).toString(16)
24 return `f-${hex}`
25}
26
27// 입력칸 값은 한 줄 글이다. 엔진은 제어 문자를 품은 Button 라벨·text 자식을 그리지 않고
28// 패널 전체를 자기 대체 화면으로 바꾸므로, 값을 받는 자리와 그리는 자리(Button 라벨·답
29// 요약), 보내는 자리(답 묶음)에서 걸러 낸다. 줄바꿈·탭은 공백 하나로 바꾸고 나머지 제어
30// 문자(C0·DEL·C1)는 뺀다.
31export const inputValue = (v: string): string =>
32 v.replace(/\r\n|[\r\n\t]/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
33
34export const draftOf = (drafts: Drafts, id: string): Draft => drafts[id] ?? EMPTY_DRAFT
35
36// 고치기 전에 보관된 초안에는 제어 문자가 남아 있을 수 있으므로 거른 글로 판정한다.
37export function isAnswered(q: FormQuestion, d: Draft): boolean {
38 const other = inputValue(d.other).trim()
39 if (q.kind === 'text') return other !== ''
40 return d.selected.length > 0 || other !== ''
41}
42
43// `when` 이 가리키는 앞 질문이 보이고 그 질문에서 라벨 하나라도 골랐을 때만 보인다.
44export function isVisible(form: FormInput, drafts: Drafts, q: FormQuestion): boolean {
45 if (!q.when) return true
46 const when = q.when
47 const target = form.questions.find(x => x.id === when.id)
48 if (!target || !isVisible(form, drafts, target)) return false
49 return draftOf(drafts, target.id).selected.some(s => when.selected.includes(s))
50}
51
52export function counts(form: FormInput, drafts: Drafts): { answered: number; total: number } {
53 const visible = form.questions.filter(q => isVisible(form, drafts, q))
54 return { answered: visible.filter(q => isAnswered(q, draftOf(drafts, q.id))).length, total: visible.length }
55}
56
57// 문답 기록 `### 답 n` 본문. 라벨은 받은 그대로 쓰고 추천 접미를 붙이지 않는다 — 추천은
58// 묶음의 `recommended` 가 따로 싣는다. single 은 고른 라벨,
59// multi 는 한 줄에 하나, 선택지 대신 쓴 자유 입력은 축자, multi 라벨 옆의
60// 자유 입력은 마지막 줄 `자유 입력:`, 메모는 맨 끝 `메모:`, 답이 없으면 `미답`.
61export function answerBody(q: FormQuestion, d: Draft): string {
62 const lines: string[] = []
63 const other = d.other.trim() === '' ? '' : d.other
64 if (!isAnswered(q, d)) {
65 lines.push(UNANSWERED)
66 } else if (q.kind === 'text') {
67 lines.push(other)
68 } else if (q.kind === 'single') {
69 lines.push(d.selected.length > 0 ? d.selected[0] : other)
70 } else {
71 for (const s of d.selected) lines.push(s)
72 if (other !== '') lines.push(d.selected.length > 0 ? `자유 입력: ${other}` : other)
73 }
74 if (d.note.trim() !== '') lines.push(`메모: ${d.note}`)
75 return lines.join('\n')
76}
77
78export type BundleAnswer = {
79 id: string
80 header: string
81 group?: string
82 question: string
83 kind: FormQuestion['kind']
84 options: string[]
85 recommended?: { label: string; by: NonNullable<FormOption['recommended']> }[]
86 state: '답' | '미답' | '해당 없음'
87 selected: string[]
88 other: string
89 note: string
90 answer: string
91}
92
93export type BundleJson = { schema: 'cc-form-answers/1'; form: string; status: FormStatus; answers: BundleAnswer[] }
94
95export function bundleJson(formId: string, status: FormStatus, form: FormInput, drafts: Drafts): BundleJson {
96 const answers = form.questions.map((q): BundleAnswer => {
97 const raw = draftOf(drafts, q.id)
98 const d: Draft = { ...raw, other: inputValue(raw.other), note: inputValue(raw.note) }
99 const options = (q.options ?? []).map(o => o.label)
100 const recommended = (q.options ?? []).flatMap(o => (o.recommended ? [{ label: o.label, by: o.recommended }] : []))
101 const base = {
102 id: q.id,
103 header: q.header,
104 ...(q.group ? { group: q.group } : {}),
105 question: q.question,
106 kind: q.kind,
107 options,
108 ...(recommended.length > 0 ? { recommended } : {}),
109 }
110 if (!isVisible(form, drafts, q)) {
111 return { ...base, state: NOT_APPLICABLE, selected: [], other: '', note: '', answer: '' }
112 }
113 return {
114 ...base,
115 state: isAnswered(q, d) ? '답' : UNANSWERED,
116 selected: [...d.selected],
117 other: d.other,
118 note: d.note,
119 answer: answerBody(q, d),
120 }
121 })
122 return { schema: 'cc-form-answers/1', form: formId, status, answers }
123}
124
125export function headerLine(formId: string, status: FormStatus, answered: number, total: number): string {
126 return `[cc-cmds 질문지 답] form=${formId} status=${status} 답=${answered}/${total}`
127}
128
129export function bundleText(formId: string, status: FormStatus, form: FormInput, drafts: Drafts): string {
130 const { answered, total } = counts(form, drafts)
131 const json = JSON.stringify(bundleJson(formId, status, form, drafts))
132 return `${headerLine(formId, status, answered, total)}\n\`\`\`json\n${json}\n\`\`\``
133}
134
135export function parseHeader(text: string): { form: string; status: FormStatus } | undefined {
136 const m = HEADER_RE.exec(text)
137 return m ? { form: m[1], status: m[2] as FormStatus } : undefined
138}
139hooks/question-form/layout.ts 309 lines1// 질문지 패널의 줄 모형. 기록과 포커스에서 그릴 줄의 목록을 만들고(layoutRows), 같은
2// 줄로 패널이 바랄 크기를 잰다(paneSize). render.tsx 는 줄을 요소로 옮길 뿐이다.
3// `$` 를 쓰지 않는다.
4
5import { counts, draftOf, inputValue, isAnswered, isVisible } from './bundle'
6import {
7 ANSWER_LABEL,
8 CANCEL_LABEL,
9 EDITOR_SUBMIT_LABEL,
10 HELP_LINE,
11 NEXT_LABEL,
12 NOTE_ADD_LABEL,
13 NOTE_LABEL,
14 NOTE_PLACEHOLDER,
15 OTHER_LABEL,
16 OTHER_PLACEHOLDER,
17 PREV_LABEL,
18 SUBMIT_LABEL,
19 SUMMARY_MAX,
20 TEXT_PLACEHOLDER,
21 TO_SUBMIT_LABEL,
22 UNANSWERED,
23 counterLine,
24} from './spec'
25import type { FormOption, FormQuestion } from './spec'
26import { questionOf } from './transitions'
27import type { FormEditor, FormRecord } from './transitions'
28import type { Draft } from './bundle'
29
30// 선택지·입력칸·버튼의 key. 미리보기는 포커스 값의 q<i>-o<j> 꼴에서 선택지를 찾는다.
31export const foldedKey = (qi: number) => `q${qi}`
32export const optionKey = (qi: number, oi: number) => `q${qi}-o${oi}`
33export const otherKey = (qi: number) => `q${qi}-other`
34export const noteKey = (qi: number) => `q${qi}-note`
35export const answerKey = (qi: number) => `q${qi}-answer`
36// 입력칸의 key 는 세대마다 다르다. 표면은 Enter 뒤 그 key 의 글을 비우고, 그려진 value 는
37// 앞 그림과 값이 다를 때만 다시 적용하므로, 같은 key 로 다시 열면 빈 칸이 보인다.
38export const editorKey = (qi: number, field: FormEditor['field'], gen: number) => `q${qi}-${field}-input-${gen}`
39export const PREV_KEY = 'prev'
40export const NEXT_KEY = 'next'
41export const SUBMIT_KEY = 'submit'
42export const CANCEL_KEY = 'cancel'
43
44export type Row =
45 | { kind: 'blank' }
46 | { kind: 'title'; text: string; counter: string }
47 | { kind: 'intro'; text: string }
48 | { kind: 'group'; text: string }
49 | { kind: 'folded'; key: string; n: number; header: string; summary: string; answered: boolean }
50 | { kind: 'current'; n: number; header: string; question: string }
51 | { kind: 'detail'; text: string }
52 | { kind: 'option'; key: string; hotkey?: string; glyph: string; label: string; recommended?: string; description: string; autoFocus: boolean }
53 | { kind: 'preview'; text: string }
54 | { kind: 'other'; key: string; hotkey: string; glyph: string; text: string; description: string }
55 | { kind: 'answer'; key: string; text: string; autoFocus: boolean }
56 | { kind: 'editor'; key: string; qid: string; field: FormEditor['field']; value: string; placeholder: string; autoFocus: boolean }
57 | { kind: 'spacer'; lines: number }
58 | { kind: 'note'; key: string; hotkey: string; text: string }
59 | { kind: 'nav'; prev: boolean; next: 'question' | 'submit' | 'none' }
60 | { kind: 'actions'; submit: string; cancel: string }
61 | { kind: 'help'; text: string }
62
63const glyphOf = (q: FormQuestion, on: boolean) => (q.kind === 'multi' ? (on ? '■' : '□') : on ? '●' : '○')
64
65const cut = (s: string, max: number) => {
66 const cps = [...s.replace(/\s+/g, ' ').trim()]
67 return cps.length > max ? `${cps.slice(0, max - 1).join('')}…` : cps.join('')
68}
69
70// 선택지 설명 줄의 들여쓰기: 단축키·표지(`1: ● `) 너비만큼 라벨 밑으로 맞춘다.
71export const descriptionIndent = (hotkey?: string) => (hotkey ? 5 : 2)
72
73// 빈 줄은 둘 겹치지 않고, 맨 앞에도 두지 않는다.
74function pushBlank(rows: Row[]) {
75 const last = rows.at(-1)
76 if (last && last.kind !== 'blank') rows.push({ kind: 'blank' })
77}
78
79// 접힌 질문 줄의 답 요약 한 줄. 라벨은 추천 접미 없이 보이고, 메모가 있으면 끝에 표시한다.
80export function answerSummary(q: FormQuestion, d: Draft): string {
81 const other = inputValue(d.other).trim()
82 const parts: string[] = []
83 if (!isAnswered(q, d)) parts.push(UNANSWERED)
84 else if (q.kind === 'text') parts.push(other)
85 else {
86 for (const s of d.selected) parts.push(s)
87 if (other !== '') parts.push(`${OTHER_LABEL}: ${other}`)
88 }
89 const line = cut(parts.join(' · '), SUMMARY_MAX)
90 return inputValue(d.note).trim() === '' ? line : `${line} · +${NOTE_LABEL}`
91}
92
93// 포커스가 이 질문의 선택지 버튼에 있으면 그 선택지의 미리보기, 아니면 undefined.
94export function previewFor(qi: number, q: FormQuestion, focused: string | null): string | undefined {
95 const m = focused === null ? null : /^q(\d+)-o(\d+)$/.exec(focused)
96 if (!m || Number(m[1]) !== qi) return undefined
97 return (q.options ?? [])[Number(m[2]) - 1]?.preview
98}
99
100// 커서 질문이 열릴 때 포커스가 설 곳: 열린 입력칸, text 의 답 줄, 아니면 첫 선택지.
101export function landingKey(rec: FormRecord): string {
102 const qi = rec.form.questions.findIndex(q => q.id === rec.cursor) + 1
103 const q = questionOf(rec, rec.cursor)
104 if (!q || qi < 1) return SUBMIT_KEY
105 if (rec.editor && rec.editor.id === rec.cursor) return editorKey(qi, rec.editor.field, rec.editor.gen)
106 if (q.kind === 'text') return answerKey(qi)
107 return (q.options ?? []).length > 0 ? optionKey(qi, 1) : otherKey(qi)
108}
109
110// 편집기 줄의 들여쓰기와, 같은 Input 줄 안에 그려지는 「 ⏎ 확정」 몫.
111export const EDITOR_INDENT = 4
112export const EDITOR_SUBMIT_WIDTH = displayWidth(` ⏎ ${EDITOR_SUBMIT_LABEL}`)
113
114// 칸보다 긴 글을 치면 엔진은 칸 안을 말줄임으로 그리고 커서·조합 글자를 칸 아래 줄에
115// 놓는다. 그 자리가 메모 줄을 덮지 않도록 편집기 줄 뒤에 넘칠 줄 수만큼 빈 줄을 둔다.
116// 엔진이 감는 폭은 모르므로 들여쓰기와 확정 몫을 모두 뺀 좁은 폭으로 나누고, 조합 중인
117// 한글 한 글자 몫 2칸을 더해 남는 쪽으로 어림한다.
118export function spacerLines(text: string, bodyColumns: number): number {
119 const width = Math.max(20, bodyColumns - EDITOR_INDENT - EDITOR_SUBMIT_WIDTH)
120 return Math.max(0, Math.ceil((EDITOR_INDENT + displayWidth(text) + 2) / width) - 1)
121}
122
123// 편집기 줄과, 넘칠 글이면 그 뒤의 빈 줄. 앞말 라벨은 두지 않는다 — 바로 위 줄(기타·메모
124// 줄이나 펼친 질문의 머리말)이 이미 그 칸이 무엇인지 말한다.
125function editorRows(rec: FormRecord, qi: number, q: FormQuestion, field: FormEditor['field'], autoFocus: boolean, bodyColumns: number): Row[] {
126 const ed = rec.editor
127 if (!ed || ed.id !== q.id || ed.field !== field) return []
128 const placeholder = field === 'note' ? NOTE_PLACEHOLDER : q.kind === 'text' ? q.placeholder ?? TEXT_PLACEHOLDER : OTHER_PLACEHOLDER
129 const rows: Row[] = [{ kind: 'editor', key: editorKey(qi, field, ed.gen), qid: q.id, field, value: inputValue(ed.seed), placeholder, autoFocus }]
130 const d = draftOf(rec.drafts, q.id)
131 const lines = spacerLines(inputValue(field === 'note' ? d.note : d.other), bodyColumns)
132 if (lines > 0) rows.push({ kind: 'spacer', lines })
133 return rows
134}
135
136function currentRows(rec: FormRecord, qi: number, q: FormQuestion, focused: string | null, bodyColumns: number): Row[] {
137 const d = draftOf(rec.drafts, q.id)
138 const landing = landingKey(rec)
139 const rows: Row[] = [{ kind: 'current', n: qi, header: q.header, question: q.question }]
140 if (q.detail) rows.push({ kind: 'detail', text: q.detail })
141 pushBlank(rows)
142 if (q.kind === 'text') {
143 const editor = editorRows(rec, qi, q, 'other', landing === editorKey(qi, 'other', rec.editor?.gen ?? 0), bodyColumns)
144 const answer = inputValue(d.other)
145 if (editor.length > 0) rows.push(...editor)
146 else rows.push({ kind: 'answer', key: answerKey(qi), text: answer.trim() === '' ? UNANSWERED : answer, autoFocus: landing === answerKey(qi) })
147 } else {
148 ;(q.options ?? []).forEach((o: FormOption, index) => {
149 const key = optionKey(qi, index + 1)
150 rows.push({
151 kind: 'option',
152 key,
153 ...(index < 9 ? { hotkey: String(index + 1) } : {}),
154 glyph: glyphOf(q, d.selected.includes(o.label)),
155 label: o.label,
156 ...(o.recommended ? { recommended: o.recommended } : {}),
157 description: o.description,
158 autoFocus: landing === key,
159 })
160 })
161 const preview = previewFor(qi, q, focused)
162 if (preview !== undefined) rows.push({ kind: 'preview', text: preview })
163 if (q.allowOther !== false) {
164 const other = inputValue(d.other).trim()
165 rows.push({
166 kind: 'other',
167 key: otherKey(qi),
168 hotkey: '0',
169 glyph: glyphOf(q, other !== ''),
170 text: other === '' ? OTHER_LABEL : `${OTHER_LABEL}: ${other}`,
171 description: other === '' ? OTHER_PLACEHOLDER : '',
172 })
173 rows.push(...editorRows(rec, qi, q, 'other', landing === editorKey(qi, 'other', rec.editor?.gen ?? 0), bodyColumns))
174 }
175 }
176 if (q.allowNote !== false) {
177 const note = inputValue(d.note).trim()
178 rows.push({ kind: 'note', key: noteKey(qi), hotkey: 'm', text: note === '' ? NOTE_ADD_LABEL : `${NOTE_LABEL}: ${note}` })
179 rows.push(...editorRows(rec, qi, q, 'note', landing === editorKey(qi, 'note', rec.editor?.gen ?? 0), bodyColumns))
180 }
181 const ids = rec.form.questions.filter(x => isVisible(rec.form, rec.drafts, x)).map(x => x.id)
182 const at = ids.indexOf(q.id)
183 pushBlank(rows)
184 rows.push({ kind: 'nav', prev: at > 0, next: at < ids.length - 1 ? 'question' : 'submit' })
185 return rows
186}
187
188// bodyColumns 는 편집기 뒤 빈 줄 수를 어림할 본문 폭이다. 없으면 80 으로 어림한다.
189export function layoutRows(rec: FormRecord, focused: string | null, bodyColumns = 80): Row[] {
190 const { form, drafts } = rec
191 const { answered, total } = counts(form, drafts)
192 const rows: Row[] = [{ kind: 'title', text: form.title, counter: counterLine(answered, total) }]
193 if (form.intro) rows.push({ kind: 'intro', text: form.intro })
194 pushBlank(rows)
195 let group: string | undefined
196 form.questions.forEach((q, index) => {
197 if (!isVisible(form, drafts, q)) return
198 const qi = index + 1
199 if (q.group && q.group !== group) {
200 pushBlank(rows)
201 rows.push({ kind: 'group', text: q.group })
202 }
203 group = q.group
204 // 펼친 질문은 위아래 빈 줄로 접힌 줄들과 떼어 놓는다.
205 if (q.id === rec.cursor) {
206 if (rows.at(-1)?.kind !== 'group') pushBlank(rows)
207 rows.push(...currentRows(rec, qi, q, focused, bodyColumns))
208 pushBlank(rows)
209 return
210 }
211 const d = draftOf(drafts, q.id)
212 rows.push({ kind: 'folded', key: foldedKey(qi), n: qi, header: q.header, summary: answerSummary(q, d), answered: isAnswered(q, d) })
213 })
214 pushBlank(rows)
215 rows.push({ kind: 'actions', submit: SUBMIT_LABEL, cancel: CANCEL_LABEL })
216 rows.push({ kind: 'help', text: HELP_LINE })
217 return rows
218}
219
220// 한 줄이 보이는 대로의 글. 크기를 재는 데만 쓴다.
221export function rowText(row: Row): string {
222 switch (row.kind) {
223 case 'blank':
224 return ''
225 case 'title':
226 return `${row.text} ${row.counter}`
227 case 'folded':
228 return ` ${row.n} ${row.header} ${row.summary}`
229 case 'current':
230 return `▸ ${row.n} ${row.header} ${row.question}`
231 case 'detail':
232 case 'intro':
233 return ` ${row.text}`
234 case 'option':
235 return (
236 ` ${row.hotkey ? `${row.hotkey}: ` : ''}${row.glyph} ${row.label}${row.recommended ? ` (${row.recommended})` : ''}` +
237 (row.description ? `\n${' '.repeat(2 + descriptionIndent(row.hotkey))}${row.description}` : '')
238 )
239 case 'preview':
240 return ` │ ${row.text}`
241 case 'other':
242 return ` ${row.hotkey}: ${row.glyph} ${row.text} ${row.description}`
243 case 'answer':
244 return ` ${ANSWER_LABEL}: ${row.text}`
245 case 'editor':
246 return `${' '.repeat(EDITOR_INDENT)}${row.value === '' ? row.placeholder : row.value} ⏎ ${EDITOR_SUBMIT_LABEL}`
247 case 'spacer':
248 return '\n'.repeat(row.lines - 1)
249 case 'note':
250 return ` ${row.hotkey}: ${row.text}`
251 case 'nav':
252 return ` ${row.prev ? `p: ${PREV_LABEL} ` : ''}${row.next === 'question' ? `n: ${NEXT_LABEL}` : row.next === 'submit' ? `n: ${TO_SUBMIT_LABEL}` : ''}`
253 case 'actions':
254 return `[ ${row.submit} ] [ ${row.cancel} ]`
255 case 'group':
256 case 'help':
257 return row.text
258 }
259}
260
261// 터미널 칸 수: 한글·한중일 글자와 이모지는 두 칸, 나머지는 한 칸으로 어림한다.
262export function displayWidth(s: string): number {
263 let w = 0
264 for (const ch of s) {
265 const cp = ch.codePointAt(0) ?? 0
266 if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f)) continue
267 const wide =
268 (cp >= 0x1100 && cp <= 0x115f) ||
269 (cp >= 0x2e80 && cp <= 0xa4cf) ||
270 (cp >= 0xac00 && cp <= 0xd7a3) ||
271 (cp >= 0xf900 && cp <= 0xfaff) ||
272 (cp >= 0xfe30 && cp <= 0xfe4f) ||
273 (cp >= 0xff00 && cp <= 0xff60) ||
274 (cp >= 0xffe0 && cp <= 0xffe6) ||
275 (cp >= 0x1f300 && cp <= 0x1faff) ||
276 (cp >= 0x20000 && cp <= 0x3fffd)
277 w += wide ? 2 : 1
278 }
279 return w
280}
281
282export const ROWS_MIN = 6
283export const ROWS_MAX = 40
284export const COLUMNS_MIN = 60
285export const COLUMNS_MAX = 100
286
287// 감겨도 되는 산문 줄. 가로 크기를 잴 때 빼고, 세로를 잴 때는 감긴 줄 수로 센다.
288const PROSE: Row['kind'][] = ['intro', 'detail', 'preview', 'help']
289
290// 패널이 바랄 크기. 세로는 지금 보이는 줄이 bodyColumns 에서 감기는 줄 수에, 커서 질문의
291// 가장 긴 미리보기 자리를 더한 것(포커스가 옮겨 미리보기가 나타나도 크기가 흔들리지
292// 않게). 가로는 산문이 아닌 가장 긴 줄에 맞추되 60~100칸 안이다. 둘 다 요청일 뿐 결정은
293// 엔진이 한다. 엔진은 Input 을 한 줄로 그리므로 편집기 줄은 글 길이와 상관없이 한 줄로
294// 세고, 넘친 몫은 그 뒤의 빈 줄(spacer)만 센다.
295export function paneSize(rec: FormRecord, bodyColumns: number): { rows: number; columns: number } {
296 const rows = layoutRows(rec, null, bodyColumns)
297 const widest = rows
298 .filter(r => !PROSE.includes(r.kind))
299 .reduce((w, r) => Math.max(w, ...rowText(r).split('\n').map(displayWidth)), 0)
300 const columns = Math.min(COLUMNS_MAX, Math.max(COLUMNS_MIN, widest + 4))
301 const width = Math.max(20, bodyColumns)
302 const lines = (s: string) => s.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(displayWidth(line) / width)), 0)
303 let height = rows.reduce((n, r) => n + (r.kind === 'editor' ? 1 : lines(rowText(r))), 0)
304 const q = questionOf(rec, rec.cursor)
305 const previews = (q?.options ?? []).map(o => (o.preview === undefined ? 0 : lines(o.preview)))
306 height += Math.max(0, ...previews)
307 return { rows: Math.min(ROWS_MAX, Math.max(ROWS_MIN, height + 1)), columns }
308}
309hooks/question-form/render.tsx 170 lines1// 질문지 패널의 그리기. `$` 를 받지 않는다 — 요소 표와 줄 모형, 처리기 함수를 인자로
2// 받는 순수 함수다. 처리기 안의 `$` 호출은 index.tsx 가 정의한다.
3
4import { EDITOR_SUBMIT_LABEL, NEXT_LABEL, PREV_LABEL, TO_SUBMIT_LABEL, UNANSWERED } from './spec'
5import { CANCEL_KEY, EDITOR_INDENT, NEXT_KEY, PREV_KEY, SUBMIT_KEY, descriptionIndent } from './layout'
6import type { Row } from './layout'
7import type { FormEditor } from './transitions'
8
9// $.ui.resolve(e) 가 돌려주는 요소 표 가운데 이 패널이 쓰는 것.
10export type FormElements = { Box: any; Text: any; Button: any; Input: any }
11
12export type FormHandlers = {
13 choose: (qid: string, label: string) => void
14 jump: (qid: string) => void
15 openEditor: (qid: string, field: FormEditor['field']) => void
16 typeText: (qid: string, field: FormEditor['field'], value: string) => void
17 commitText: (qid: string, field: FormEditor['field'], value: string) => void
18 next: () => void
19 prev: () => void
20 submit: () => void
21 cancel: () => void
22}
23
24// 포커스를 받은 버튼은 엔진이 반전해 그리므로, 버튼에는 고르는 대상(번호·표지·라벨)만
25// 담고 설명·추천·답 요약은 버튼 밖에 흐리게 둔다. 강조색은 펼친 질문의 머리말에만 쓴다.
26
27// 접힌 질문 줄과 선택지 줄은 key 로 질문 번호를 되찾는다.
28const qidOfKey = (key: string) => Number(/^q(\d+)/.exec(key)?.[1] ?? 0)
29
30export function drawForm(el: FormElements, rows: Row[], ids: string[], on: FormHandlers) {
31 const { Box, Text, Button, Input } = el
32 const qid = (key: string) => ids[qidOfKey(key) - 1] ?? ''
33 const auto = (on: boolean) => (on ? { autoFocus: true as const } : {})
34 const drawn = rows.map(row => {
35 switch (row.kind) {
36 case 'blank':
37 return <Text> </Text>
38 case 'title':
39 return (
40 <Text>
41 <Text bold>{row.text}</Text>
42 <Text dimColor>{` ${row.counter}`}</Text>
43 </Text>
44 )
45 case 'intro':
46 case 'detail':
47 return (
48 <Box paddingLeft={2}>
49 <Text dimColor>{row.text}</Text>
50 </Box>
51 )
52 case 'group':
53 return <Text bold>{row.text}</Text>
54 case 'folded':
55 return (
56 <Box paddingLeft={2} flexDirection="row" gap={2}>
57 <Button key={row.key} plain onPress={() => on.jump(qid(row.key))}>
58 {`${row.n} ${row.header}`}
59 </Button>
60 {row.answered ? <Text dimColor>{row.summary}</Text> : <Text color="warning">{row.summary}</Text>}
61 </Box>
62 )
63 case 'current':
64 return (
65 <Text>
66 <Text bold color="suggestion">{`▸ ${row.n} ${row.header}`}</Text>
67 <Text bold>{` ${row.question}`}</Text>
68 </Text>
69 )
70 case 'option':
71 return (
72 <Box paddingLeft={2} flexDirection="column">
73 <Box flexDirection="row" gap={2}>
74 <Button key={row.key} plain {...(row.hotkey ? { hotkey: row.hotkey } : {})} {...auto(row.autoFocus)} onPress={() => on.choose(qid(row.key), row.label)}>
75 {`${row.glyph} ${row.label}`}
76 </Button>
77 {row.recommended ? <Text dimColor>{`(${row.recommended})`}</Text> : ''}
78 </Box>
79 {row.description ? (
80 <Box paddingLeft={descriptionIndent(row.hotkey)}>
81 <Text dimColor>{row.description}</Text>
82 </Box>
83 ) : (
84 ''
85 )}
86 </Box>
87 )
88 case 'preview':
89 return (
90 <Box paddingLeft={4} flexDirection="column">
91 {row.text.split('\n').map(line => (
92 <Text>
93 <Text dimColor>{'│ '}</Text>
94 {line}
95 </Text>
96 ))}
97 </Box>
98 )
99 case 'other':
100 return (
101 <Box paddingLeft={2} flexDirection="row" gap={2}>
102 <Button key={row.key} plain hotkey={row.hotkey} onPress={() => on.openEditor(qid(row.key), 'other')}>
103 {`${row.glyph} ${row.text}`}
104 </Button>
105 {row.description ? <Text dimColor>{row.description}</Text> : ''}
106 </Box>
107 )
108 case 'answer':
109 return (
110 <Box paddingLeft={2}>
111 <Button key={row.key} plain {...auto(row.autoFocus)} onPress={() => on.openEditor(qid(row.key), 'other')}>
112 {row.text === UNANSWERED ? <Text dimColor>{row.text}</Text> : row.text}
113 </Button>
114 </Box>
115 )
116 case 'editor':
117 return (
118 <Box paddingLeft={EDITOR_INDENT}>
119 <Input
120 key={row.key}
121 {...auto(row.autoFocus)}
122 placeholder={row.placeholder}
123 value={row.value}
124 submitLabel={EDITOR_SUBMIT_LABEL}
125 onInput={(v: string) => on.typeText(row.qid, row.field, v)}
126 onSubmit={(v: string) => on.commitText(row.qid, row.field, v)}
127 />
128 </Box>
129 )
130 case 'spacer':
131 return (
132 <Box flexDirection="column">
133 {Array.from({ length: row.lines }, () => (
134 <Text> </Text>
135 ))}
136 </Box>
137 )
138 case 'note':
139 return (
140 <Box paddingLeft={2}>
141 <Button key={row.key} plain hotkey={row.hotkey} dimColor onPress={() => on.openEditor(qid(row.key), 'note')}>
142 {row.text}
143 </Button>
144 </Box>
145 )
146 case 'nav':
147 return (
148 <Box paddingLeft={2} flexDirection="row" gap={2}>
149 {row.prev ? <Button key={PREV_KEY} plain hotkey="p" dimColor label={PREV_LABEL} onPress={() => on.prev()} /> : ''}
150 {row.next !== 'none' ? (
151 <Button key={NEXT_KEY} plain hotkey="n" dimColor label={row.next === 'submit' ? TO_SUBMIT_LABEL : NEXT_LABEL} onPress={() => on.next()} />
152 ) : (
153 ''
154 )}
155 </Box>
156 )
157 case 'actions':
158 return (
159 <Box flexDirection="row" gap={1}>
160 <Button key={SUBMIT_KEY} variant="primary" label={row.submit} onPress={() => on.submit()} />
161 <Button key={CANCEL_KEY} label={row.cancel} onPress={() => on.cancel()} />
162 </Box>
163 )
164 case 'help':
165 return <Text dimColor>{row.text}</Text>
166 }
167 })
168 return <Box flexDirection="column">{drawn}</Box>
169}
170hooks/question-form/spec.ts 145 lines1// 질문지 도구의 계약: 입력 모양, 예약 라벨, 상한, 그리고 모델과 사람에게
2// 보이는 문면 상수. `$` 를 쓰지 않는다.
3
4import type { FormInput, FormOption, FormQuestion } from '../../types'
5
6// 입력 모양은 $.state 계약 파일이 갖는다. 기록이 입력을 그대로 담기 때문이다.
7export type { FormInput, FormOption, FormQuestion }
8
9export const TOOL_NAME = 'question_form'
10export const TOOL_FULL_NAME = 'mcp__cc-cmds__question_form'
11export const PANE_ID = 'cc-cmds-question-form'
12export const COMMAND_NAME = 'question-form'
13export const TOKEN_PREFIX = 'QUESTION_FORM_'
14
15export const TITLE_MAX = 40
16export const HEADER_MAX = 12
17export const ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/
18export const TOTAL_MAX = 90000
19export const FORM_ID_RE = /^f-[0-9a-f]{8}$/
20
21// 문답 기록의 첫 답 줄을 라벨과 대조하는 읽기가 모호해지지 않게 막는 라벨.
22export const RESERVED_LABELS = ['미답', '해당 없음']
23export const RESERVED_PREFIXES = ['메모:', '자유 입력:']
24// 「기타」는 allowOther 가 그리므로 손으로 만든 같은 계열 라벨을 받지 않는다.
25export const HANDMADE_OTHER_LABELS = ['기타', '직접 입력', '직접 지정', 'Other']
26export const RECOMMEND_ARROW = ' ← '
27
28// 제어 문자 거절 사유. 한 줄로 그려지는 칸은 줄바꿈·탭까지 모든 제어 문자를, 여러 줄로
29// 감기는 칸은 엔진이 받는 줄바꿈·탭·CR 말고 나머지를 거절한다.
30export const LINE_CONTROL_REASON = '제어 문자를 넣지 않습니다(줄바꿈·탭 포함). 한 줄로 그려지는 칸입니다'
31export const PROSE_CONTROL_REASON = '줄바꿈·탭 말고는 제어 문자를 넣지 않습니다'
32const LINE_RULE = '제어 문자를 넣지 않는다(줄바꿈·탭 포함)'
33const PROSE_RULE = '줄바꿈·탭 말고는 제어 문자를 넣지 않는다'
34
35export const UNAVAILABLE_REASONS = ['subagent', 'pipeline', 'surface', 'remote', 'error'] as const
36export type UnavailableReason = (typeof UNAVAILABLE_REASONS)[number]
37
38export const TOOL_DESCRIPTION = [
39 '사람에게 여러 질문을 한 장의 질문지 패널로 묻는다. 지금 물을 수 있는 질문이 둘 이상이거나, 한 질문이 선택지 다섯 개 이상·메모·자유 입력만의 답·복수 선택의 미리보기를 필요로 할 때 쓴다.',
40 `결과의 첫 토큰을 읽는다(<tool_use_error> 감싸개를 벗기고 처음 나오는 ${TOKEN_PREFIX}[A-Z]+).`,
41 `${TOKEN_PREFIX}OPEN 이면 이 턴을 곧바로 끝낸다. 답은 사람이 제출하면 새 턴에 [cc-cmds 질문지 답] 으로 시작하는 메시지로 온다.`,
42 `${TOKEN_PREFIX}INVALID 이면 입력을 고쳐 다시 부르고, ${TOKEN_PREFIX}BUSY 이면 replaces 로 갈아 끼우거나 답을 기다리고, ${TOKEN_PREFIX}UNAVAILABLE 이면 AskUserQuestion 으로 묻는다.`,
43].join(' ')
44
45const OPTION_SCHEMA = {
46 type: 'object',
47 properties: {
48 label: { type: 'string', description: `선택지 라벨. 「 ← 」를 넣지 않는다. 추천은 recommended 로만 표시한다. ${LINE_RULE}` },
49 description: { type: 'string', description: `선택지 설명. ${PROSE_RULE}` },
50 preview: { type: 'string', description: `포커스가 이 선택지에 있을 때 질문 아래에 보일 미리보기. ${PROSE_RULE}` },
51 recommended: { type: 'string', enum: ['추천', '에이전트 추천'], description: 'single 질문에는 하나까지' },
52 },
53 required: ['label', 'description'],
54 additionalProperties: false,
55} as const
56
57export const INPUT_SCHEMA = {
58 type: 'object',
59 properties: {
60 title: { type: 'string', description: `패널 제목. ${TITLE_MAX}자 이하. ${LINE_RULE}` },
61 intro: { type: 'string', description: `첫 질문 위 안내 문단. ${PROSE_RULE}` },
62 replaces: { type: 'string', description: '지금 열린 질문지 id. 그 질문지를 이 질문지로 갈아 끼운다' },
63 questions: {
64 type: 'array',
65 minItems: 1,
66 items: {
67 type: 'object',
68 properties: {
69 id: { type: 'string', description: '^[a-z0-9][a-z0-9_-]{0,31}$, 질문지 안에서 유일' },
70 header: { type: 'string', description: `짧은 머리말. NFC 기준 ${HEADER_MAX} 코드포인트 이하. ${LINE_RULE}` },
71 group: { type: 'string', description: `같은 값끼리 묶어 보인다. ${LINE_RULE}` },
72 question: { type: 'string', description: `질문 문장. ${PROSE_RULE}` },
73 detail: { type: 'string', description: `질문 아래 설명. ${PROSE_RULE}` },
74 kind: { type: 'string', enum: ['single', 'multi', 'text'] },
75 options: {
76 type: 'array',
77 items: OPTION_SCHEMA,
78 description: 'single·multi 는 2개 이상, text 는 두지 않는다. 「기타」·「직접 입력」 같은 라벨은 만들지 않는다(allowOther 가 그린다)',
79 },
80 allowOther: { type: 'boolean', description: '기본 true. 「기타」 자유 입력을 보인다' },
81 allowNote: { type: 'boolean', description: '기본 true. 「메모 (선택)」 입력칸을 보인다' },
82 placeholder: { type: 'string', description: `text 입력칸 안내. ${LINE_RULE}` },
83 when: {
84 type: 'object',
85 properties: {
86 id: { type: 'string', description: '앞쪽 single·multi 질문의 id' },
87 selected: { type: 'array', items: { type: 'string' }, description: '그 질문의 라벨 가운데 하나라도 고르면 이 질문을 보인다' },
88 },
89 required: ['id', 'selected'],
90 additionalProperties: false,
91 },
92 },
93 required: ['id', 'header', 'question', 'kind'],
94 additionalProperties: false,
95 },
96 },
97 },
98 required: ['title', 'questions'],
99 additionalProperties: false,
100} as const
101
102// 결과 토큰과 본문.
103export function openResult(id: string, count: number, isPlaced: boolean): string {
104 const lines = [
105 `${TOKEN_PREFIX}OPEN ${id} 질문 ${count}건`,
106 '질문지를 열었습니다. 이 턴을 지금 끝내세요: 다른 도구를 부르지 말고, 답을 짐작하거나 질문을 본문에 다시 적지 말고, 차례 넘김 표지를 쓰지 마세요(질문지 배너가 이미 나갔습니다).',
107 `답은 사람이 제출하면 새 턴에 \`[cc-cmds 질문지 답] form=${id}\` 로 시작하는 메시지로 옵니다.`,
108 ]
109 if (!isPlaced) {
110 lines.push('패널이 좁은 화면이라 아직 그려지지 않았습니다. 상태 줄의 /question-form 안내가 사람에게 보이므로 따로 알리지 말고 턴을 끝내세요.')
111 }
112 return lines.join('\n')
113}
114
115export const invalidResult = (field: string, reason: string) => `${TOKEN_PREFIX}INVALID ${field}: ${reason}`
116export const busyResult = (id: string) => `${TOKEN_PREFIX}BUSY ${id}`
117export const unavailableResult = (reason: UnavailableReason) => `${TOKEN_PREFIX}UNAVAILABLE ${reason}`
118
119// 사람에게 보이는 문면.
120export const formTitle = (title: string, answered: number, total: number) => `질문지 — ${title} (답 ${answered}/${total})`
121export const sentToast = (answered: number, total: number) => `질문지 답을 보냈습니다 · 답 ${answered}/${total}`
122export const statusLine = (answered: number, total: number) => `질문지 대기 중 · 답 ${answered}/${total} · /question-form 으로 열기`
123export const contextLine = (id: string) => `질문지 ${id} 가 열려 있습니다(답 대기). 이 입력은 질문지의 답이 아닙니다. 답은 질문지 제출로만 옵니다.`
124export const STAMP = '직접 입력된 문면입니다. 질문지 제출이 아닙니다.'
125export const NO_FORM_TOAST = '열린 질문지가 없습니다.'
126export const HELP_LINE = '1-9 고르기 · 0 기타 · m 메모 · n 다음 · p 이전 · Tab 이동 · Enter 확정 · Esc 프롬프트로'
127export const TEXT_PLACEHOLDER = '답을 입력하세요'
128export const OTHER_LABEL = '기타'
129export const OTHER_PLACEHOLDER = '직접 입력'
130export const NOTE_LABEL = '메모'
131export const NOTE_ADD_LABEL = '메모 추가'
132export const NOTE_PLACEHOLDER = '메모 (선택)'
133export const EDITOR_SUBMIT_LABEL = '확정'
134export const ANSWER_LABEL = '답'
135export const UNANSWERED = '미답'
136export const NOT_APPLICABLE = '해당 없음'
137export const SUBMIT_LABEL = '제출'
138export const CANCEL_LABEL = '답 없이 닫기'
139export const PREV_LABEL = '이전 질문'
140export const NEXT_LABEL = '다음 질문'
141export const TO_SUBMIT_LABEL = '제출로'
142export const counterLine = (answered: number, total: number) => `답 ${answered}/${total} · 미답 ${total - answered}건`
143// 접힌 질문 줄의 답 요약은 이 글자 수에서 자른다.
144export const SUMMARY_MAX = 48
145hooks/question-form/transitions.ts 193 lines1// 질문지 수명 주기의 순수 전이. 훅과 처리기는 이 함수들이 돌려준 기록을
2// `$.state` 에 쓰고, 패널·상태 줄·제출·포커스 같은 바깥 일은 스스로 한다.
3// 시험 키트는 사람의 닫기를 일으킬 수 없으므로 전이는 여기서 따로 시험한다.
4
5import { draftOf, inputValue, isVisible } from './bundle'
6import { holdsRefusedControl } from './validate'
7import type { Draft, Drafts, FormEditor, FormInput, FormPhase, FormRecord } from '../../types'
8
9export type { FormEditor, FormPhase, FormRecord }
10
11export const STORE_TTL_MS = 7 * 24 * 60 * 60 * 1000
12
13export const isOpen = (r: FormRecord | undefined): r is FormRecord => r !== undefined && r.phase === 'open'
14
15// 도구 호출이 무엇이 되는가: 열린 질문지가 있으면 replaces 일 때만 갈아 끼우고
16// 아니면 BUSY. 아무것도 없으면 새로 연다(배너는 새로 연 경우만).
17export type CallDecision = { kind: 'busy'; id: string } | { kind: 'replace' } | { kind: 'new' }
18
19export function decideCall(current: FormRecord | undefined, replaces: string | undefined): CallDecision {
20 if (isOpen(current)) return replaces === current.id ? { kind: 'replace' } : { kind: 'busy', id: current.id }
21 return { kind: 'new' }
22}
23
24// 갈아 끼울 때 id·종류·라벨 집합이 같은 질문만 쓰던 답을 이어 받는다.
25export function carryDrafts(prev: FormInput, prevDrafts: Drafts, next: FormInput): Drafts {
26 const out: Drafts = {}
27 for (const q of next.questions) {
28 const old = prev.questions.find(x => x.id === q.id)
29 const d = prevDrafts[q.id]
30 if (!old || !d || old.kind !== q.kind) continue
31 const a = (old.options ?? []).map(o => o.label).sort()
32 const b = (q.options ?? []).map(o => o.label).sort()
33 if (a.length === b.length && a.every((l, i) => l === b[i])) out[q.id] = d
34 }
35 return out
36}
37
38export function openRecord(args: {
39 id: string
40 toolUseId: string
41 form: FormInput
42 drafts: Drafts
43 sessionId: string
44 now: number
45}): FormRecord {
46 const { now, ...rest } = args
47 const rec: FormRecord = { ...rest, phase: 'open', hidden: false, cursor: '', editor: null, editorGen: 0, savedAt: now }
48 return land(settleCursor(rec))
49}
50
51// 열기 결과 반영: 배치되지 않았으면 숨김(상태 줄).
52export const placed = (r: FormRecord, isPlaced: boolean): FormRecord => ({ ...r, hidden: !isPlaced })
53
54// 보이는 질문의 id 를 차례로.
55export const visibleIds = (r: FormRecord): string[] =>
56 r.form.questions.filter(q => isVisible(r.form, r.drafts, q)).map(q => q.id)
57
58export const questionOf = (r: FormRecord, id: string) => r.form.questions.find(q => q.id === id)
59
60// 커서가 보이지 않는 질문을 가리키면(when 의 앞 답이 바뀌었거나 보관에서 되살렸거나)
61// 가장 가까운 앞쪽의 보이는 질문으로, 그것도 없으면 첫 질문으로 옮기고 입력칸을 닫는다.
62export function settleCursor(r: FormRecord): FormRecord {
63 const ids = visibleIds(r)
64 if (ids.includes(r.cursor)) return r
65 const order = r.form.questions.map(q => q.id)
66 const at = order.indexOf(r.cursor)
67 const before = at < 0 ? [] : ids.filter(id => order.indexOf(id) < at)
68 return { ...r, cursor: before[before.length - 1] ?? ids[0] ?? r.cursor, editor: null }
69}
70
71// 커서가 답 없는 text 질문에 놓이면 그 답 칸을 연다. 답이 있으면 답 줄을 보인다.
72export function land(r: FormRecord): FormRecord {
73 const q = questionOf(r, r.cursor)
74 if (!q || q.kind !== 'text') return r
75 if (r.editor && r.editor.id === r.cursor) return r
76 return inputValue(draftOf(r.drafts, r.cursor).other).trim() === '' ? openEditor(r, r.cursor, 'other') : r
77}
78
79// 커서 이동. 보이지 않는 질문으로는 가지 않는다.
80export function jump(r: FormRecord, id: string): FormRecord {
81 if (!visibleIds(r).includes(id) || id === r.cursor) return r
82 return land({ ...r, cursor: id, editor: null })
83}
84
85// 다음·이전 보이는 질문. 끝이면 undefined.
86export function advance(r: FormRecord): FormRecord | undefined {
87 const ids = visibleIds(r)
88 const next = ids[ids.indexOf(r.cursor) + 1]
89 return next === undefined ? undefined : jump(r, next)
90}
91
92export function retreat(r: FormRecord): FormRecord | undefined {
93 const ids = visibleIds(r)
94 const at = ids.indexOf(r.cursor)
95 const prev = at > 0 ? ids[at - 1] : undefined
96 return prev === undefined ? undefined : jump(r, prev)
97}
98
99// 입력칸 열기. 세대가 하나 오르고 그때의 답이 씨앗이 된다 — 입력칸의 key 는 세대마다
100// 다르고 그려지는 value 는 씨앗으로 고정이라, 표면이 Enter 뒤에 비운 글이 다시
101// 열린 칸에 묻어 오지 않고, 치는 동안의 다시 그리기가 치던 글을 되돌리지 않는다.
102// 세대는 기록 안에서 되돌지 않는다: 표면은 한 번 쓴 key 의 빈 글을 패널이 사는 동안 기억한다.
103export function openEditor(r: FormRecord, id: string, field: FormEditor['field']): FormRecord {
104 const gen = r.editorGen + 1
105 const d = draftOf(r.drafts, id)
106 return { ...r, cursor: id, editor: { id, field, gen, seed: field === 'note' ? d.note : d.other }, editorGen: gen }
107}
108
109export const closeEditor = (r: FormRecord): FormRecord => (r.editor ? { ...r, editor: null } : r)
110
111function withDraft(r: FormRecord, id: string, patch: Partial<Draft>): FormRecord {
112 return { ...r, drafts: { ...r.drafts, [id]: { ...draftOf(r.drafts, id), ...patch } } }
113}
114
115// 전이 뒤 포커스가 갈 곳: 그대로, 다음 질문의 첫 조작부, 질문이 끝나 [제출].
116export type Move = 'stay' | 'next' | 'end'
117
118function forward(r: FormRecord): { record: FormRecord; move: Move } {
119 const next = advance(r)
120 return next ? { record: next, move: 'next' } : { record: r, move: 'end' }
121}
122
123// 선택지 누름. single 은 고르면 기타를 비우고 다음 질문으로, 다시 누르면 풀린다.
124// multi 는 켜고 끈다. 그 질문의 기타 입력칸이 열려 있었으면 닫는다.
125export function choose(r: FormRecord, id: string, label: string): { record: FormRecord; move: Move } {
126 const q = questionOf(r, id)
127 const d = draftOf(r.drafts, id)
128 if (!q || q.kind === 'text') return { record: r, move: 'stay' }
129 const base = r.editor?.id === id && r.editor.field === 'other' ? closeEditor(r) : r
130 if (q.kind === 'multi') {
131 const selected = d.selected.includes(label) ? d.selected.filter(s => s !== label) : [...d.selected, label]
132 return { record: settleCursor(withDraft(base, id, { selected })), move: 'stay' }
133 }
134 if (d.selected.includes(label)) return { record: settleCursor(withDraft(base, id, { selected: [] })), move: 'stay' }
135 return forward(settleCursor(withDraft(base, id, { selected: [label], other: '' })))
136}
137
138// 입력칸의 글이 바뀔 때마다: 답을 바로 적는다(확정 없이 나가도 답으로 남는다).
139// single 의 기타에 글이 있으면 고른 선택지는 풀린다. 초안에는 거른 글만 적는다.
140export function typeText(r: FormRecord, id: string, field: FormEditor['field'], value: string): FormRecord {
141 value = inputValue(value)
142 if (field === 'note') return withDraft(r, id, { note: value })
143 const q = questionOf(r, id)
144 const patch: Partial<Draft> = q?.kind === 'single' && value.trim() !== '' ? { other: value, selected: [] } : { other: value }
145 return settleCursor(withDraft(r, id, patch))
146}
147
148// Enter 로 확정: 답을 적고 입력칸을 닫는다. single 의 기타와 text 의 답은 글이 있으면
149// 다음 질문으로 간다. 메모와 multi 의 기타는 그 자리에 남는다.
150export function commitText(r: FormRecord, id: string, field: FormEditor['field'], value: string): { record: FormRecord; move: Move } {
151 const typed = typeText(r, id, field, value)
152 const record = typed.editor?.id === id && typed.editor.field === field ? closeEditor(typed) : typed
153 const q = questionOf(record, id)
154 const moves = field === 'other' && inputValue(value).trim() !== '' && (q?.kind === 'single' || q?.kind === 'text')
155 return moves ? forward(record) : { record, move: 'stay' }
156}
157
158// [제출]: CAS open→submitted. 이미 소비된 기록이면 undefined(두 번째 누름 무시).
159export function submit(r: FormRecord | undefined): FormRecord | undefined {
160 if (!isOpen(r)) return undefined
161 return { ...r, phase: 'submitted', hidden: false }
162}
163
164// [답 없이 닫기]: CAS open→cancelled.
165export function cancel(r: FormRecord | undefined): FormRecord | undefined {
166 if (!isOpen(r)) return undefined
167 return { ...r, phase: 'cancelled' }
168}
169
170// 사람의 닫기 표시: 열린 질문지는 숨길 뿐 기록과 BUSY 를 유지한다.
171export function personClose(r: FormRecord | undefined): { record: FormRecord | undefined; showStatus: boolean } {
172 if (isOpen(r)) return { record: { ...r, hidden: true }, showStatus: true }
173 return { record: undefined, showStatus: false }
174}
175
176// /question-form: 열린 질문지를 다시 보인다. 없으면 undefined(알림).
177export function reopen(r: FormRecord | undefined): FormRecord | undefined {
178 return isOpen(r) ? { ...r, hidden: false } : undefined
179}
180
181export const expired = (stored: FormRecord, now: number) => now - stored.savedAt > STORE_TTL_MS
182
183// 보관에서 되살릴지: 같은 세션의 열린 기록만 되살린다. now 를 주면 7일이 지난 기록은
184// 같은 세션의 것이어도 되살리지 않는다. 앞 판이 보관한 기록에는 커서와 입력칸이
185// 없으므로 첫 질문에서 시작하게 채운다. 모델 입력에 엔진이 거부하는 제어 문자가 든
186// 기록(검사가 생기기 전에 열린 것)은 그려도 패널이 깨지므로 되살리지 않는다.
187export function restorable(stored: FormRecord | undefined, sessionId: string, now?: number): FormRecord | undefined {
188 if (!isOpen(stored) || stored.sessionId !== sessionId) return undefined
189 if (now !== undefined && expired(stored, now)) return undefined
190 if (holdsRefusedControl(stored.form)) return undefined
191 return land(settleCursor({ ...stored, cursor: stored.cursor ?? '', editor: stored.editor ?? null, editorGen: stored.editorGen ?? 0 }))
192}
193hooks/question-form/validate.ts 159 lines1// 질문지 입력 전체 검증. 엔진은 입력 스키마를 강제하지 않으므로 이 파일이
2// 유일한 검사다. 첫 위반 하나를 `QUESTION_FORM_INVALID <칸>: <사유>` 로 돌려준다.
3
4import {
5 HANDMADE_OTHER_LABELS,
6 HEADER_MAX,
7 ID_RE,
8 LINE_CONTROL_REASON,
9 PROSE_CONTROL_REASON,
10 RECOMMEND_ARROW,
11 RESERVED_LABELS,
12 RESERVED_PREFIXES,
13 TITLE_MAX,
14 TOTAL_MAX,
15 invalidResult,
16} from './spec'
17import type { FormInput } from './spec'
18
19const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
20const isStr = (v: unknown): v is string => typeof v === 'string'
21const codePoints = (s: string) => [...s].length
22
23// 입력 안 모든 문자열의 글자 수 합.
24export function totalChars(v: unknown): number {
25 if (isStr(v)) return codePoints(v)
26 if (Array.isArray(v)) return v.reduce((n: number, x) => n + totalChars(x), 0)
27 if (isObj(v)) return Object.values(v).reduce((n: number, x) => n + totalChars(x), 0)
28 return 0
29}
30
31// 한 줄로 그려지는 칸(제목·머리말·묶음·안내글·라벨)은 C0·DEL·C1 전부를, 여러 줄로
32// 감기는 칸(안내 문단·질문 문장·설명·선택지 설명·미리보기)은 엔진이 받는 줄바꿈·탭·CR 을
33// 뺀 나머지를 금한다. 뒤쪽 집합이 곧 엔진이 패널 전체를 거부하는 문자다.
34const LINE_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/
35const PROSE_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/
36
37// 엔진이 거부하는 문자를 품은 칸이 하나라도 있는가. 보관에서 되살릴 기록은 이 검사가
38// 생기기 전에 열렸을 수 있으므로 되살리기 전에 다시 건다. 한 줄 칸의 줄바꿈·탭은 엔진이
39// 그리므로 여기서는 걸지 않는다.
40export function holdsRefusedControl(form: FormInput): boolean {
41 const refused = (s: string | undefined) => s !== undefined && PROSE_CONTROL_RE.test(s)
42 if (refused(form.title) || refused(form.intro)) return true
43 return form.questions.some(
44 q =>
45 refused(q.header) ||
46 refused(q.question) ||
47 refused(q.group) ||
48 refused(q.detail) ||
49 refused(q.placeholder) ||
50 (q.options ?? []).some(o => refused(o.label) || refused(o.description) || refused(o.preview)),
51 )
52}
53
54function labelProblem(label: string): string | undefined {
55 if (label.includes(RECOMMEND_ARROW.trim())) return '라벨에 「←」를 넣지 않습니다. 추천은 recommended 로 표시합니다'
56 if (HANDMADE_OTHER_LABELS.includes(label.trim())) return `「${label.trim()}」 라벨은 만들지 않습니다. allowOther 가 기타 입력을 그립니다`
57 if (RESERVED_LABELS.includes(label.trim())) return `「${label.trim()}」는 예약 라벨입니다`
58 for (const p of RESERVED_PREFIXES) if (label.trim().startsWith(p)) return `「${p}」로 시작하는 라벨은 예약돼 있습니다`
59 return undefined
60}
61
62// openId: 지금 열린(제출·취소되지 않은) 질문지 id. 없으면 undefined.
63export function validateForm(input: unknown, openId: string | undefined): string | undefined {
64 const bad = invalidResult
65 if (!isObj(input)) return bad('input', '객체가 아닙니다')
66 if (!isStr(input.title) || input.title.trim() === '') return bad('title', '필수입니다')
67 if (codePoints(input.title) > TITLE_MAX) return bad('title', `${TITLE_MAX}자를 넘습니다`)
68 if (LINE_CONTROL_RE.test(input.title)) return bad('title', LINE_CONTROL_REASON)
69 if (input.intro !== undefined && !isStr(input.intro)) return bad('intro', '문자열이 아닙니다')
70 if (input.intro !== undefined && PROSE_CONTROL_RE.test(input.intro)) return bad('intro', PROSE_CONTROL_REASON)
71 if (input.replaces !== undefined) {
72 if (!isStr(input.replaces)) return bad('replaces', '문자열이 아닙니다')
73 if (input.replaces !== openId) return bad('replaces', openId ? `열린 질문지 id 는 ${openId} 입니다` : '열린 질문지가 없습니다')
74 }
75 if (!Array.isArray(input.questions) || input.questions.length === 0) return bad('questions', '1개 이상이어야 합니다')
76
77 const seen = new Map<string, { index: number; kind: string; labels: string[] }>()
78 for (let i = 0; i < input.questions.length; i++) {
79 const q = input.questions[i]
80 const at = `questions[${i}]`
81 if (!isObj(q)) return bad(at, '객체가 아닙니다')
82 if (!isStr(q.id) || !ID_RE.test(q.id)) return bad(`${at}.id`, '^[a-z0-9][a-z0-9_-]{0,31}$ 에 맞지 않습니다')
83 if (seen.has(q.id)) return bad(`${at}.id`, `「${q.id}」가 겹칩니다`)
84 if (!isStr(q.header) || q.header.trim() === '') return bad(`${at}.header`, '필수입니다')
85 if (codePoints(q.header.normalize('NFC')) > HEADER_MAX) return bad(`${at}.header`, `NFC 기준 ${HEADER_MAX} 코드포인트를 넘습니다`)
86 if (LINE_CONTROL_RE.test(q.header)) return bad(`${at}.header`, LINE_CONTROL_REASON)
87 if (!isStr(q.question) || q.question.trim() === '') return bad(`${at}.question`, '필수입니다')
88 if (PROSE_CONTROL_RE.test(q.question)) return bad(`${at}.question`, PROSE_CONTROL_REASON)
89 for (const k of ['group', 'detail', 'placeholder'] as const) {
90 const v = q[k]
91 if (v === undefined) continue
92 if (!isStr(v)) return bad(`${at}.${k}`, '문자열이 아닙니다')
93 const prose = k === 'detail'
94 if ((prose ? PROSE_CONTROL_RE : LINE_CONTROL_RE).test(v)) return bad(`${at}.${k}`, prose ? PROSE_CONTROL_REASON : LINE_CONTROL_REASON)
95 }
96 for (const k of ['allowOther', 'allowNote'] as const) {
97 if (q[k] !== undefined && typeof q[k] !== 'boolean') return bad(`${at}.${k}`, '참·거짓 값이 아닙니다')
98 }
99 if (q.kind !== 'single' && q.kind !== 'multi' && q.kind !== 'text') return bad(`${at}.kind`, 'single · multi · text 가운데 하나여야 합니다')
100
101 const labels: string[] = []
102 if (q.kind === 'text') {
103 if (q.options !== undefined && (!Array.isArray(q.options) || q.options.length > 0)) return bad(`${at}.options`, 'text 질문에는 선택지를 두지 않습니다')
104 } else {
105 if (!Array.isArray(q.options) || q.options.length < 2) return bad(`${at}.options`, `${q.kind} 질문은 선택지가 2개 이상이어야 합니다`)
106 let recommended = 0
107 for (let j = 0; j < q.options.length; j++) {
108 const o = q.options[j]
109 const oat = `${at}.options[${j}]`
110 if (!isObj(o)) return bad(oat, '객체가 아닙니다')
111 if (!isStr(o.label) || o.label.trim() === '') return bad(`${oat}.label`, '필수입니다')
112 if (LINE_CONTROL_RE.test(o.label)) return bad(`${oat}.label`, LINE_CONTROL_REASON)
113 if (!isStr(o.description)) return bad(`${oat}.description`, '필수입니다')
114 if (PROSE_CONTROL_RE.test(o.description)) return bad(`${oat}.description`, PROSE_CONTROL_REASON)
115 if (o.preview !== undefined && !isStr(o.preview)) return bad(`${oat}.preview`, '문자열이 아닙니다')
116 if (isStr(o.preview) && PROSE_CONTROL_RE.test(o.preview)) return bad(`${oat}.preview`, PROSE_CONTROL_REASON)
117 const problem = labelProblem(o.label)
118 if (problem) return bad(`${oat}.label`, problem)
119 if (labels.includes(o.label)) return bad(`${oat}.label`, `「${o.label}」가 겹칩니다`)
120 labels.push(o.label)
121 if (o.recommended !== undefined) {
122 if (o.recommended !== '추천' && o.recommended !== '에이전트 추천') return bad(`${oat}.recommended`, '「추천」이나 「에이전트 추천」이어야 합니다')
123 recommended++
124 }
125 }
126 if (q.kind === 'single' && recommended > 1) return bad(`${at}.options`, 'single 질문에 추천이 둘 이상입니다')
127 }
128
129 if (q.when !== undefined) {
130 const w = q.when
131 if (!isObj(w) || !isStr(w.id) || !Array.isArray(w.selected) || w.selected.length === 0 || !w.selected.every(isStr)) {
132 return bad(`${at}.when`, '{id, selected[]} 모양이어야 합니다')
133 }
134 const target = seen.get(w.id)
135 if (!target) return bad(`${at}.when`, `「${w.id}」는 앞쪽 질문이 아닙니다`)
136 if (target.kind === 'text') return bad(`${at}.when`, `「${w.id}」는 single·multi 질문이 아닙니다`)
137 const missing = (w.selected as string[]).find(s => !target.labels.includes(s))
138 if (missing !== undefined) return bad(`${at}.when`, `「${w.id}」에 「${missing}」 라벨이 없습니다`)
139 }
140 seen.set(q.id, { index: i, kind: q.kind, labels })
141 }
142
143 if (totalChars(input) > TOTAL_MAX) return bad('input', `글자 총량이 ${TOTAL_MAX}자를 넘습니다`)
144 return undefined
145}
146
147// 검증을 통과한 입력에 기본값을 채운다.
148export function normalizeForm(input: FormInput): FormInput {
149 return {
150 ...input,
151 questions: input.questions.map(q => ({
152 ...q,
153 options: q.kind === 'text' ? [] : q.options ?? [],
154 allowOther: q.allowOther ?? true,
155 allowNote: q.allowNote ?? true,
156 })),
157 }
158}
159types/index.d.ts 115 lines1// 런 상태 패널 mod(hooks/autopilot-status.tsx)와 질문지 서브 mod(hooks/question-form/)가
2// $.state 에 두는 값의 계약.
3
4/** 한 줄 안의 색조 조각. 색조는 헬퍼가 정하고 mod 는 그리기만 한다. */
5export type PanePart = { tone: string; bold: boolean; text: string }
6
7/**
8 * 헬퍼 본문 한 줄: 묶음 id, 감을지(`wrap`) 끝을 자를지(`cut`), 그리고 조각들.
9 * 줄 하나는 한 번 감기거나 한 번 잘린다.
10 */
11export type PaneLine = { bundle: string; wrap: boolean; parts: PanePart[] }
12
13/** 패널이 그리는 것: 마지막으로 성공한 헬퍼 실행의 본문과, 그 뒤 실패를 알리는 경고 줄. */
14export type PaneLines = { lines: PaneLine[]; warning: string | null }
15
16/** 자동 열기·닫힘 기록과 갱신 주기 판단에 쓰는 값. 모듈이 다시 적재되어도 남아야 한다. */
17export type PaneControl = {
18 /** 묻지 않고 패널을 연 런 id. 런마다 한 번만 연다. */
19 autoOpenedFor: string[]
20 /** 사람이 패널을 닫은 런 id. 이 런으로는 다시 저절로 열지 않는다. */
21 dismissedFor: string[]
22 /** 마지막 헬퍼 실행을 시작하기 전에 읽은 시각(ms). 실행한 적 없으면 null. */
23 lastRunAt: number | null
24 /** 마지막으로 본 세션 목록 mtime. 목록이 없으면 null. */
25 lastMtime: number | null
26 /** 마지막 헬퍼 실행에 넘긴 세션 id. */
27 lastSid: string | null
28 /** 마지막 좋은 머리 행의 런 id. 런이 없으면 null. */
29 rid: string | null
30 /** 마지막 좋은 머리 행의 세션 목록 절대 경로. 없으면 null. */
31 indexPath: string | null
32 /** 마지막 좋은 머리 행의 갱신 주기(ms). */
33 refreshMs: number
34}
35
36/** 질문지 선택지. 추천 표시는 라벨이 아니라 recommended 로만 한다. */
37export type FormOption = {
38 label: string
39 description: string
40 preview?: string
41 recommended?: '추천' | '에이전트 추천'
42}
43
44/** 질문지 질문 하나. when 은 앞쪽 single·multi 질문의 라벨에 건다. */
45export type FormQuestion = {
46 id: string
47 header: string
48 group?: string
49 question: string
50 detail?: string
51 kind: 'single' | 'multi' | 'text'
52 options?: FormOption[]
53 allowOther?: boolean
54 allowNote?: boolean
55 placeholder?: string
56 when?: { id: string; selected: string[] }
57}
58
59/** 질문지 도구의 입력. */
60export type FormInput = {
61 title: string
62 intro?: string
63 replaces?: string
64 questions: FormQuestion[]
65}
66
67/** 쓰던 답. selected 는 라벨 원문(추천 접미 없음), other 는 기타 입력이나 text 질문의 답. */
68export type Draft = { selected: string[]; other: string; note: string }
69export type Drafts = Record<string, Draft>
70
71export type FormPhase = 'open' | 'submitted' | 'cancelled'
72
73/**
74 * 열려 있는 입력칸 하나: 어느 질문의 어느 칸인지, 열 때마다 하나씩 오르는 세대(입력칸 key 에
75 * 들어간다), 연 순간의 글(그려지는 value 로 고정된다).
76 */
77export type FormEditor = { id: string; field: 'other' | 'note'; gen: number; seed: string }
78
79/** 질문지 기록 하나. 제출·취소된 기록은 묶음을 낸 자리에서 버려지므로 열린 것만 남는다. */
80export type FormRecord = {
81 id: string
82 toolUseId: string
83 form: FormInput
84 drafts: Drafts
85 phase: FormPhase
86 /** 사람이 닫기 표시를 눌렀거나 열 때 배치되지 않아 패널 대신 상태 줄을 보이는 중. */
87 hidden: boolean
88 /** 펼쳐 보이는 질문의 id. 나머지 질문은 한 줄로 접힌다. */
89 cursor: string
90 /** 열려 있는 「기타」·「메모」 입력칸. 없으면 null. */
91 editor: FormEditor | null
92 /** 이 기록에서 입력칸을 연 횟수. 입력칸 key 의 세대이며, 칸을 닫아도 되돌지 않는다. */
93 editorGen: number
94 sessionId: string
95 savedAt: number
96}
97
98/** 질문지가 읽는 사람 프롬프트의 출처 낱말(composer·bridge 등). 모르면 null. */
99export type PromptOriginKind = string | null
100
101declare module 'claude-code' {
102 interface PluginState {
103 'cc-cmds': {
104 paneLines: PaneLines
105 paneControl: PaneControl
106 /** 지금 열린 질문지 기록. 없으면 null. */
107 'questionForm.record': FormRecord | null
108 /** 마지막 사람 프롬프트의 출처. bridge 이면 질문지를 쓸 수 없다. */
109 'questionForm.lastPersonOrigin': PromptOriginKind
110 /** 질문지 패널에서 포커스를 받은 요소의 key. 엔진 자신의 정지점이면 null. */
111 'questionForm.focusedElement': string | null
112 }
113 }
114}
115