SLOPSHOPPER

cc-cmds

Engineering workflow commands for Claude Code

newpanebandguardcommandtoast
★ 2v2.62.1MITupdated 2026-10-10Nharu/cc-cmds/plugins/cc-cmds
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-cmds
│ ┃ autopilot ✕ › fix the failing auth test and add an audit log ca╭───────────────╮ │ ┃ ⚠ 런 상태를 읽지 못했다 (출력 형식이 맞지 않… │ cc-cmds │ │ ⏺ Read(src/auth.ts) │ 열린 질문지가 없습니다. │ │ ⎿ Read 6 lines ╰───────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /autopilot-status │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · autopilot
⚠ 런 상태를 읽지 못했다 (출력 형식이 맞지 않음)
Pane · cc-cmds-question-form
열린 질문지가 없습니다.
README

cc-cmds

Engineering workflow commands for Claude Code.

Commands

<!-- SKILLS_TABLE_START -->

CommandDescriptionWhen 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-applyClaude 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-ingestClaude Design (claude.ai/design) 핸드오프 번들을 파싱·리뷰하고 ACCEPT/REFINE 판정으로 개선 루프 진행claude.ai/design 에서 받은 HTML 핸드오프 번들을 검토·수용·재프롬프트할 때 (단일 호출 또는 외부 재실행 사이 반복)
/cc-cmds:design-lite2인 팀을 활용한 경량 설계 토론깊은 다관점 분석보다 빠른 방향 설정이 우선될 때 (sonnet 단독 합성으로 미묘한 invariant 누락 가능)
/cc-cmds:design-promptClaude 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-systemClaude 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-lite2인 팀을 활용한 경량 코드 리뷰빠른 코드 리뷰가 목적이고 다관점 심층 분석이 불필요할 때 (큰 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 -->

Prerequisites

/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단계 — 자연어 요청:

  • 단발 알림 — 사용자가 지정한 시점(작업 완료 등)에 모델이 알림을 1회 발송합니다. 여러 시점을 함께 지정하면 각 시점마다 발송합니다.
  • 반복 알림 — 모델이 작업이 진행된 매 turn 종료 직전 자체적으로 알림을 발송합니다 (취소할 때까지). hook이 아닌 모델 판단 기반이라, 모델이 turn 끝의 호출을 놓치면 해당 turn 알림이 누락될 수 있습니다.

사용 예시 (대화 중 이렇게 말하면 됩니다):

이렇게 말하세요동작
"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가 아니면 알림은 오류 없이 비활성화됩니다.

Claude Design 핸드오프 품질 리뷰 (선택)

/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 권장-설치-명령 패턴을 따릅니다.

Install

# 1. 마켓플레이스 등록
/plugin marketplace add Nharu/cc-cmds

# 2. 플러그인 설치
/plugin install cc-cmds@cc-cmds

Usage

/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 참조.

Update

/plugin update cc-cmds

Uninstall

/plugin uninstall cc-cmds

Options

<!-- SKILLS_OPTIONS_START -->

/cc-cmds:autopilot

Usage: /cc-cmds:autopilot <의도 또는 대상> [--report]

OptionDefaultSummary
<의도 또는 대상>(required)이 런이 무엇에 관한 것인지 — 설계 문서 경로(.md), 레포 슬러그, PR·브랜치 참조, 또는 아직 산출물이 없는 자유 텍스트 의도. 앵커 종류는 1막의 진입 판정이 정한다.
--reportoff (킥오프 모드 — 1막 인터뷰 후 드라이버 기동)아침 보고 모드. 그 런의 매니페스트·원장·보고서, 설계 문서, 그리고 매니페스트나 run 행이 없을 때 킥오프 흔적을 읽어 한국어로 렌더링만 하고, 새 런을 시작하지 않는다.

Parsing (<의도 또는 대상>): $ARGUMENTS 전체를 의도로 읽는다. .md 토큰이 있으면 문서 앵커 후보로 우선 해석하되, 최종 앵커 종류는 진입 판정과 사용자 확인이 정한다.

/cc-cmds:autopilot-router-shift

Usage: (사람이 치는 커맨드가 아니다 — gate.sh act --kind router-shift 가 claude -p 로 넘긴다)

autopilot 의 Act 2b 를 대신 도는 헤드리스 좌석이다. 사람에게 묻는 자리도, 배너를 띄우는 자리도, 진행 채널을 여는 자리도 아니다 — 그 셋은 전부 리드에 남는다. 이 샤드가 하는 것은 스냅숏을 읽고 한 행위를 정해 게이트에 넘기는 것뿐이며, 끝날 때 후임이 읽을 인수인계 행 하나를 남긴다.

/cc-cmds:design

Usage: /cc-cmds:design <task>

OptionDefaultSummary
<task>(required)설계 토론을 진행할 작업 주제 (자유형 한국어/영문 텍스트).

/cc-cmds:design-analyze

Usage: /cc-cmds:design-analyze <design-doc-path> [--no-codebase] [--report-only]

OptionDefaultSummary
<design-doc-path>(required)분석 대상 제3자 설계 문서 경로 (.md). 원본은 절대 수정하지 않음.
--no-codebaseoff (코드베이스 grounding 활성)코드베이스 교차검증 비활성화 — 문서 자체만으로 분석 (doc-only 모드).
--report-onlyoff (산출물 대화형 선택)Step 7 산출물 선택 대화만 건너뛰고 보고서만 생성. Step 6 워크스루(발견별 검토)는 그대로 유지 — 완전 비대화 아님(산출물 범위 한정 플래그).

/cc-cmds:design-apply

Usage: /cc-cmds:design-apply <handoff-extract-path>

OptionDefaultSummary
<handoff-extract-path>(required)design-ingest가 확정한 안정 사본 (docs/{slug}-fe/handoff-extract.md); 본 스킬이 slug 파싱·출력 경로·원장 키의 단일 앵커

/cc-cmds:design-audit

Usage: /cc-cmds:design-audit <design-doc-path> [<note>] [--base]

OptionDefaultSummary
<design-doc-path>(required)감사 대상 설계 문서 경로 (.md). 첫 리더 spawn 직전의 sha256으로 동결되며, 감사가 끝날 때까지 어떤 바이트도 수정되지 않는다.
<note>(optional)문서 경로 뒤 자유 텍스트. 전 리더에게 축어로 동일하게 주입되는 초점 메모 (리더별로 다르게 주면 보강 통계가 무의미해지므로 금지).
--baseoffbase 설계 문서 모드 — 기존 내용의 정합·완결만 감사하고 신규 구현 세부 제안을 금지한다. FE 파이프라인이 확장한 base 문서의 호출 형태.

/cc-cmds:design-audit-unattended

Usage: /cc-cmds:design-audit-unattended <design-doc-path> [<note>] [--base]

OptionDefaultSummary
<design-doc-path>(required)감사 대상 설계 문서 경로 (.md). 드라이버가 메인 워크트리 절대 경로로 넘긴다. 첫 리더 spawn 직전의 sha256으로 동결된다.
<note>(optional)문서 경로 뒤 자유 텍스트. 전 리더에게 축어로 동일하게 주입되는 초점 메모.
--baseoffbase 설계 문서 모드 — 기존 내용의 정합·완결만 감사하고 신규 구현 세부 제안을 금지한다.

/cc-cmds:design-base

Usage: /cc-cmds:design-base <task> | --split <문서>

OptionDefaultSummary
<task>(required)베이스 설계를 진행할 작업 주제 (자유형 한국어/영문 텍스트). --split 모드에서는 쓰지 않는다.
--split <문서>off분할 모드 — 동결·감사를 마친 베이스 설계 문서를 다시 점검하고, 고른 트래커에 베이스 티켓과 각 티켓을 발행하거나 문서만으로 분할을 기록한다.

Parsing (<task>): $ARGUMENTS 에 --split 토큰이 없으면 전체가 작업 주제다.

Parsing (--split <문서>): --split 다음의 첫 .md 토큰이 베이스 문서 경로다. 있으면 설계 흐름(Step 1–7)은 돌지 않는다.

/cc-cmds:design-base-unattended

Usage: /cc-cmds:design-base-unattended <brief-or-doc-path> [<task-sentence>] | --split <문서>

OptionDefaultSummary
<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 토큰이 베이스 문서 경로(메인 워크트리 절대 경로)다.

/cc-cmds:design-discuss-unattended

Usage: /cc-cmds:design-discuss-unattended <brief-or-doc-path> [<task-sentence>]

OptionDefaultSummary
<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 토큰 이후의 모든 내용. 좌석 파견에서는 무시한다.

/cc-cmds:design-ingest

Usage: /cc-cmds:design-ingest <handoff-dir-path>

OptionDefaultSummary
<handoff-dir-path>(required)기능 핸드오프 디렉토리 (docs/{slug}-fe/handoff); incoming/ 하위 번들을 소비

/cc-cmds:design-lite

Usage: /cc-cmds:design-lite <task>

OptionDefaultSummary
<task>(required)설계 토론을 진행할 작업 주제 (자유형 한국어/영문 텍스트).

/cc-cmds:design-prompt

Usage: /cc-cmds:design-prompt <base-doc-path>

OptionDefaultSummary
<base-doc-path>(required)base 설계 문서 경로 (docs/{slug}.md); 본 스킬이 그 안에 CD 프롬프트 섹션을 in-place authoring

/cc-cmds:design-reconverge

Usage: /cc-cmds:design-reconverge <design-doc-path> <scope>

OptionDefaultSummary
<design-doc-path>(required)재수렴 대상 설계 문서 경로 (.md). 드라이버와 라우터 교대가 메인 워크트리 절대 경로로 넘긴다.
<scope>(required)재수렴 스코프. R<n> 형태의 잔여 검증 항목 식별자, (정규화 파일 경로, 카테고리 태그) 형태의 문제 동일성, 감사 종합 요구 <중단 기록 절대 경로> 형태의 채택된 감사 종합 요구, 또는 구속 티어 이탈 <중단 기록 절대 경로> 형태의 사람이 재수렴을 고른 구속 티어 이탈.

Parsing (<design-doc-path>): $ARGUMENTS의 첫 .md 토큰을 경로로 해석.

Parsing (<scope>): 첫 .md 토큰 이후의 모든 내용. 비어 있으면 중단 기록을 남기고 정지 — 스코프 없는 재설계는 이 스킬이 하는 일이 아니다.

/cc-cmds:design-system

Usage: /cc-cmds:design-system [<intent>]

OptionDefaultSummary
[<intent>](optional)DS 생성 의도/스코프 서술용 자유형 토큰 (생략 시 base 설계·코드베이스에서 추론)

/cc-cmds:design-upgrade

Usage: /cc-cmds:design-upgrade

이 커맨드는 별도 인자를 받지 않으며, 직전 /design 팀 구성 제안이 현재 대화 컨텍스트에 있어야 동작한다. 모델 승격과 역할 추가·분할은 강화가 유의미할 때만 제안하며, 그 외에는 유지 사유를 제시한다. 독립 실행 시 결과가 불정확할 수 있다.

/cc-cmds:implement

Usage: /cc-cmds:implement <design-doc-path> [scope-directive]

OptionDefaultSummary
<design-doc-path>(required)구현 대상 설계 문서 경로 (.md).
[scope-directive](optional)구현 범위를 좁히는 자유형 자연어 지시문 (예: "Phase 2", "PR #0").

Parsing (<design-doc-path>): $ARGUMENTS의 첫 .md 토큰을 경로로 해석. 이후 토큰은 scope directive로 전달.

Parsing ([scope-directive]): 첫 .md 토큰 이후의 모든 내용. 단일 바깥쪽 쌍따옴표로 감싸져 있으면 그 쌍만 제거하고 안쪽 따옴표·구두점은 보존.

/cc-cmds:implement-unattended

Usage: /cc-cmds:implement-unattended <design-doc-path> [scope-directive]

OptionDefaultSummary
<design-doc-path>(required)구현 대상 설계 문서 경로 (.md). 드라이버가 메인 워크트리 절대 경로로 넘긴다.
[scope-directive](optional)구현 범위를 좁히는 자유형 자연어 지시문. 드라이버가 세그먼트 범위를, 라우터의 수정 재파견이 리뷰 리포트의 수정 대상을 이 자리에 싣는다.

Parsing (<design-doc-path>): $ARGUMENTS의 첫 .md 토큰을 경로로 해석. 이후 토큰은 scope directive로 전달.

Parsing ([scope-directive]): 첫 .md 토큰 이후의 모든 내용. 단일 바깥쪽 쌍따옴표로 감싸져 있으면 그 쌍만 제거하고 안쪽 따옴표·구두점은 보존.

/cc-cmds:review

Usage: /cc-cmds:review [<target>] [--base-sha <sha>] [--declared-files <csv>] [<directive>]

OptionDefaultSummary
<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> 입력 형태별 처리:

  • PR URL — https://github.com/owner/repo/pull/42 → PR 번호 추출 후 gh pr view로 메타데이터 수집
  • PR 번호 — 42 → 숫자만일 때 PR 번호로 해석
  • 브랜치 이름 — feat/auth-flow → 하이픈·영문 포함 시 브랜치로 해석, gh pr list --head로 연관 PR 조회
  • 파일/디렉토리 경로 — src/auth/ → 파일 리뷰 모드; gh 명령 사용 안 함
  • 혼합 (타겟 + 지시문) — PR #42 보안 중심으로 → 타겟 추출 후 지시문을 팀 구성·컨텍스트 패키지·보고서에 전파. 지시문은 깊이/커버리지에만 영향; severity는 기술 기준으로 독립 평가.
  • (생략) — 빈 입력 시 현재 브랜치/PR 자동 감지 체인 실행

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> 추출 전에 인자열에서 제거된다.

/cc-cmds:review-lite

Usage: /cc-cmds:review-lite [<target>] [--base-sha <sha>] [--declared-files <csv>]

OptionDefaultSummary
<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 다음 토큰을 값으로 취한다. 쉼표·공백을 포함할 수 있으므로 인용 부호로 감싸 넘긴다.

/cc-cmds:review-unattended

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>]

OptionDefaultSummary
<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.
--recoveroff (팀을 띄워 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-cycle takes 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-head takes 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-path takes 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 다음 토큰을 값으로 취한다. 값이 없거나 절대 경로가 아니면 지명이 없는 것으로 다뤄 열거 후 거부 경로로 간다.

/cc-cmds:review-upgrade

Usage: /cc-cmds:review-upgrade

이 커맨드는 별도 인자를 받지 않으며, 직전 /review Step 3 리뷰어 구성 제안이 현재 대화 컨텍스트에 있어야 동작한다. opus 승격, 누락 리뷰 관점 추가, 과부하 리뷰어 분할은 강화가 유의미할 때만 제안하며, 그 외에는 유지 사유를 제시한다. 독립 실행 시 결과가 불정확할 수 있다.

<!-- SKILLS_OPTIONS_END -->

License

MIT

Source 11 files
hooks/autopilot-status.tsx 349 lines
1// 런 상태 패널 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}
349
hooks/question-form/index.tsx 424 lines
1// 질문지 서브 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}
424
hooks/pipeline-marks.ts 8 lines
1// 파이프라인 표지 판정. `$` 를 받지 않는 순수 함수만 둔다 — 표지 변수는
2// 부르는 쪽이 같은 파일에서 리터럴 이름으로 읽어 값만 넘긴다.
3
4// 값이 참인 표지가 하나라도 있으면 참. 정의돼 있어도 빈 문자열이면 표지가 아니다.
5export function anyMarked(values: readonly (string | undefined | null)[]): boolean {
6  return values.some(v => typeof v === 'string' && v !== '')
7}
8
hooks/question-form/banner.ts 24 lines
1// 새로 연 질문지의 배너: 셸 훅 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}
24
hooks/question-form/bundle.ts 139 lines
1// 답 묶음: 머리줄, 머리줄 정규식, `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}
139
hooks/question-form/layout.ts 309 lines
1// 질문지 패널의 줄 모형. 기록과 포커스에서 그릴 줄의 목록을 만들고(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}
309
hooks/question-form/render.tsx 170 lines
1// 질문지 패널의 그리기. `$` 를 받지 않는다 — 요소 표와 줄 모형, 처리기 함수를 인자로
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}
170
hooks/question-form/spec.ts 145 lines
1// 질문지 도구의 계약: 입력 모양, 예약 라벨, 상한, 그리고 모델과 사람에게
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
145
hooks/question-form/transitions.ts 193 lines
1// 질문지 수명 주기의 순수 전이. 훅과 처리기는 이 함수들이 돌려준 기록을
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}
193
hooks/question-form/validate.ts 159 lines
1// 질문지 입력 전체 검증. 엔진은 입력 스키마를 강제하지 않으므로 이 파일이
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}
159
types/index.d.ts 115 lines
1// 런 상태 패널 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